/**
* @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
* For licensing, see LICENSE.md.
*/
'use strict';
import Collection from '../../utils/collection.js';
import Region from './region.js';
import Template from './template.js';
import CKEditorError from '../../utils/ckeditorerror.js';
import DOMEmitterMixin from './domemittermixin.js';
import utils from '../../utils/utils.js';
import isPlainObject from '../lib/lodash/isPlainObject.js';
const bindToSymbol = Symbol( 'bindTo' );
const bindIfSymbol = Symbol( 'bindIf' );
/**
* Basic View class.
*
* @memberOf core.ui
* @mixes DOMEmitterMixin
*/
export default class View {
/**
* Creates an instance of the {@link View} class.
*
* @param {core.ui.Model} model (View)Model of this View.
* @param {core.Locale} [locale] The {@link core.Editor#locale editor's locale} instance.
*/
constructor( model, locale ) {
/**
* Model of this view.
*
* @member {core.ui.Model} core.ui.View#model
*/
this.model = model;
/**
* @readonly
* @member {core.Locale} core.ui.View#locale
*/
this.locale = locale;
/**
* Shorthand for {@link core.Locale#t}.
*
* Note: If locale instance hasn't been passed to the view this method may not be available.
*
* @see core.Locale#t
* @method core.ui.View#t
*/
this.t = locale && locale.t;
/**
* Regions of this view. See {@link core.ui.View#register}.
*
* @member {core.Collection} core.ui.View#regions
*/
this.regions = new Collection( {
idProperty: 'name'
} );
/**
* Template of this view.
*
* @member {Object} core.ui.View#template
*/
/**
* Region selectors of this view. See {@link core.ui.View#register}.
*
* @private
* @member {Object} core.ui.View#_regionSelectors
*/
this._regionSelectors = {};
/**
* Element of this view.
*
* @private
* @member {HTMLElement} core.ui.View.#_element
*/
/**
* An instance of Template to generate {@link core.ui.View#_el}.
*
* @private
* @member {Template} core.ui.View#_template
*/
}
/**
* Element of this view. The element is rendered on first reference
* using {@link core.ui.View#template} definition.
*
* @type {HTMLElement}
*/
get element() {
if ( this._element ) {
return this._element;
}
// No template means no element (a virtual view).
if ( !this.template ) {
return null;
}
// Prepare pre–defined listeners.
this._extendTemplateWithListenerAttachers( this.template );
// Prepare pre–defined attribute bindings.
this._extendTemplateWithModelBinders( this.template );
this._template = new Template( this.template );
return ( this._element = this._template.render() );
}
set element( el ) {
this._element = el;
}
/**
* And entry point to the interface which allows binding attributes of {@link View#model}
* to the DOM items like HTMLElement attributes or Text Node `textContent`, so their state
* is synchronized with {@link View#model}.
*
* @readonly
* @type core.ui.ViewModelBinding
*/
get attributeBinder() {
if ( this._attributeBinder ) {
return this._attributeBinder;
}
const model = this.model;
const binder = {
/**
* Binds {@link View#model} to HTMLElement attribute or Text Node `textContent`
* so remains in sync with the Model when it changes.
*
* this.template = {
* tag: 'p',
* attributes: {
* // class="..." attribute gets bound to this.model.a
* 'class': bind.to( 'a' )
* },
* children: [
* //
...
gets bound to this.model.b; always `toUpperCase()`.
* { text: bind.to( 'b', ( value, node ) => value.toUpperCase() ) }
* ]
* }
*
* @property {attributeBinder.to}
* @param {String} attribute Name of {@link View#model} used in the binding.
* @param {Function} [callback] Allows processing of the value. Accepts `Node` and `value` as arguments.
* @return {core.ui.ViewModelBinding}
*/
to( attribute, callback ) {
return {
type: bindToSymbol,
model: model,
attribute,
callback
};
},
/**
* Binds {@link View#model} to HTMLElement attribute or Text Node `textContent`
* so remains in sync with the Model when it changes. Unlike {@link View#attributeBinder.to},
* it controls the presence of the attribute/`textContent` depending on the "falseness" of
* {@link View#model} attribute.
*
* this.template = {
* tag: 'input',
* attributes: {
* // this.model.a is not undefined/null/false/''
* // this.model.a is undefined/null/false
* checked: bind.if( 'a' )
* },
* children: [
* {
* // "b-is-not-set" when this.model.b is undefined/null/false/''
* // when this.model.b is not "falsy"
* text: bind.if( 'b', 'b-is-not-set', ( value, node ) => !value )
* }
* ]
* }
*
* @property {attributeBinder.if}
* @param {String} attribute Name of {@link View#model} used in the binding.
* @param {String} [valueIfTrue] Value set when {@link View#model} attribute is not undefined/null/false/''.
* @param {Function} [callback] Allows processing of the value. Accepts `Node` and `value` as arguments.
* @return {core.ui.ViewModelBinding}
*/
if( attribute, valueIfTrue, callback ) {
return {
type: bindIfSymbol,
model: model,
attribute,
valueIfTrue,
callback
};
}
};
return ( this._attributeBinder = binder );
}
/**
* Initializes the view.
*
* Note: {@link core.Controller} supports if a promise is returned by this method,
* what means that view initialization may be asynchronous.
*/
init() {
this._initRegions();
}
/**
* Registers a region in {@link core.ui.View#regions}.
*
* let view = new View();
*
* // region.name == "foo", region.element == view.element.firstChild
* view.register( 'foo', el => el.firstChild );
*
* // region.name == "bar", region.element == view.element.querySelector( 'span' )
* view.register( new Region( 'bar' ), 'span' );
*
* // region.name == "bar", region.element == view.element.querySelector( '#div#id' )
* view.register( 'bar', 'div#id', true );
*
* // region.name == "baz", region.element == 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 core.ui.View#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._regionSelectors[ regionName ] = regionSelector;
}
/**
* Applies template to existing DOM element in the context of a View.
*
* const element = document.createElement( 'div' );
* const view = new View( new Model( { divClass: 'my-div' } ) );
*
* view.applyTemplateToElement( element, {
* attrs: {
* id: 'first-div',
* class: view.bindToAttribute( 'divClass' )
* },
* on: {
* click: 'elementClicked' // Will be fired by the View instance.
* }
* children: [
* 'Div text.'
* ]
* } );
*
* element.outerHTML == "
Div text.
"
*
* See: {@link Template#apply}.
*
* @param {DOMElement} element DOM Element to initialize.
* @param {core.ui.TemplateDefinition} def Template definition to be applied.
*/
applyTemplateToElement( element, def ) {
// Prepare pre–defined listeners.
this._extendTemplateWithListenerAttachers( def );
// Prepare pre–defined attribute bindings.
this._extendTemplateWithModelBinders( def );
new Template( def ).apply( element );
}
/**
* Destroys the view instance. The process includes:
*
* 1. Removal of child views from {@link core.ui.View#regions}.
* 2. Destruction of the {@link core.ui.View#regions}.
* 3. Removal of {#link #_el} from DOM.
*/
destroy() {
let childView;
this.stopListening();
for ( let region of this.regions ) {
while ( ( childView = region.views.get( 0 ) ) ) {
region.views.remove( childView );
}
this.regions.remove( region ).destroy();
}
if ( this.template ) {
this.element.remove();
}
this.model = this.regions = this.template = this.locale = this.t = null;
this._regionSelectors = this._element = this._template = null;
}
/**
* Initializes {@link core.ui.View#regions} of this view by passing a DOM element
* generated from {@link core.ui.View#_regionSelectors} into {@link Region#init}.
*
* @protected
*/
_initRegions() {
let region, regionEl, regionSelector;
for ( region of this.regions ) {
regionSelector = this._regionSelectors[ region.name ];
if ( typeof regionSelector == 'string' ) {
regionEl = this.element.querySelector( regionSelector );
} else if ( typeof regionSelector == 'function' ) {
regionEl = regionSelector( this.element );
} else {
regionEl = null;
}
region.init( regionEl );
}
}
/**
* For a given event name or callback, returns a function which,
* once executed in a context of an element, attaches native DOM listener
* 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 listener attacher function to be executed in the context of an element.
*/
_getDOMListenerAttacher( evtNameOrCallback ) {
/**
* Attaches a native DOM listener to given element. The listener executes the
* callback or fires View's event.
*
* Note: If the selector is supplied, it narrows the scope to relevant targets only.
* So instead of
*
* children: [
* { tag: 'span', on: { click: 'foo' } }
* { tag: 'span', on: { click: 'foo' } }
* ]
*
* a single, more efficient listener can be attached that uses **event delegation**:
*
* children: [
* { tag: 'span' }
* { tag: 'span' }
* ],
* on: {
* 'click@span': 'foo',
* }
*
* @param {HTMLElement} el Element, to which the native DOM Event listener is attached.
* @param {String} domEventName The name of native DOM Event.
* @param {String} [selector] If provided, the selector narrows the scope to relevant targets only.
*/
return ( el, domEvtName, selector ) => {
// Use View's listenTo, so the listener is detached, when the View dies.
this.listenTo( el, domEvtName, ( evt, domEvt ) => {
if ( !selector || domEvt.target.matches( selector ) ) {
if ( typeof evtNameOrCallback == 'function' ) {
evtNameOrCallback( domEvt );
} else {
this.fire( evtNameOrCallback, domEvt );
}
}
} );
};
}
/**
* For given {@link core.ui.TemplateValueSchema} found by (@link _extendTemplateWithModelBinders} containing
* {@link core.ui.ViewModelBinding} it returns a function, which when called by {@link Template#render}
* or {@link Template#apply} activates the binding and sets its initial value.
*
* Note: {@link core.ui.TemplateValueSchema} can be for HTMLElement attributes or Text Node `textContent`.
*
* @protected
* @param {core.ui.TemplateValueSchema}
* @return {Function}
*/
_getModelBinder( valueSchema ) {
valueSchema = normalizeBinderValueSchema( valueSchema );
/**
* Assembles the value using {@link core.ui.TemplateValueSchema} and stores it in a form of
* an Array. Each entry of an Array corresponds to one of {@link core.ui.TemplateValueSchema}
* items.
*
* @private
* @param {Node} node
* @return {Array}
*/
const getBoundValue = ( node ) => {
let model, modelValue;
return valueSchema.map( schemaItem => {
model = schemaItem.model;
if ( model ) {
modelValue = model[ schemaItem.attribute ];
if ( schemaItem.callback ) {
modelValue = schemaItem.callback( modelValue, node );
}
return modelValue;
} else {
return schemaItem;
}
} );
};
/**
* Attaches a listener to {@link View#model}, which updates DOM with a value constructed from
* {@link core.ui.TemplateValueSchema} when {@link View#model} attribute value changes.
*
* This function is called by {@link Template#render} or {@link Template#apply}.
*
* @param {Node} node DOM Node to be updated when {@link View#model} changes.
* @param {Function} domUpdater A function provided by {@link Template} which updates corresponding
* DOM attribute or `textContent`.
*/
return ( node, domUpdater ) => {
// Check if valueSchema is a single bind.if, like:
// { class: bind.if( 'foo' ) }
const isPlainBindIf = valueSchema.length == 1 && valueSchema[ 0 ].type == bindIfSymbol;
// A function executed each time bound model attribute changes.
const onModelChange = () => {
let value = getBoundValue( node );
if ( isPlainBindIf ) {
value = value[ 0 ];
} else {
value = value.reduce( binderValueReducer, '' );
}
const isSet = isPlainBindIf ? !!value : value;
const valueToSet = isPlainBindIf ?
( valueSchema[ 0 ].valueIfTrue || '' ) : value;
if ( isSet ) {
domUpdater.set( valueToSet );
} else {
domUpdater.remove();
}
};
valueSchema
.filter( schemaItem => schemaItem.model )
.forEach( schemaItem => {
this.listenTo( schemaItem.model, 'change:' + schemaItem.attribute, onModelChange );
} );
// Set initial values.
onModelChange();
};
}
/**
* Iterates over "attributes" and "text" properties in {@link TemplateDefinition} and
* locates existing {@link core.ui.ViewModelBinding} created by {@link core.ui.View#attributeBinder}.
* Then, for each such a binding, it creates corresponding entry in {@link Template#_modelBinders},
* which can be then activated by {@link Template#render} or {@link Template#apply}.
*
* @protected
* @param {core.ui.TemplateDefinition} def
*/
_extendTemplateWithModelBinders( def ) {
const attributes = def.attributes;
const text = def.text;
let binders = def._modelBinders;
let attrName, attrValue;
if ( !binders && isPlainObject( def ) ) {
Object.defineProperty( def, '_modelBinders', {
enumerable: false,
writable: true,
value: {
attributes: {}
}
} );
binders = def._modelBinders;
}
if ( attributes ) {
for ( attrName in attributes ) {
attrValue = attributes[ attrName ];
if ( hasModelBinding( attrValue ) ) {
binders.attributes[ attrName ] = this._getModelBinder( attrValue );
}
}
}
if ( text && hasModelBinding( text ) ) {
binders.text = this._getModelBinder( text );
}
// Repeat recursively for the children.
if ( def.children ) {
def.children.forEach( this._extendTemplateWithModelBinders, this );
}
}
/**
* Iterates over "on" property in {@link TemplateDefinition} to recursively
* replace each listener declaration with a function which, once executed in a context
* of an element, attaches native DOM listener to that element.
*
* @protected
* @param {core.ui.TemplateDefinition} def Template definition.
*/
_extendTemplateWithListenerAttachers( def ) {
const on = def.on;
// Don't create attachers if they're already here or in the context of the same (this) View instance.
if ( on && ( !on._listenerAttachers || on._listenerView != this ) ) {
let domEvtName, evtNameOrCallback;
Object.defineProperty( on, '_listenerAttachers', {
enumerable: false,
writable: true,
value: {}
} );
for ( domEvtName in on ) {
evtNameOrCallback = on[ domEvtName ];
// Listeners allow definition with an array:
//
// on: {
// 'DOMEventName@selector': [ 'event1', callback ],
// 'DOMEventName': [ callback, 'event2', 'event3' ]
// ...
// }
if ( Array.isArray( evtNameOrCallback ) ) {
on._listenerAttachers[ domEvtName ] = on[ domEvtName ].map( this._getDOMListenerAttacher, this );
}
// Listeners allow definition with a string containing event name:
//
// on: {
// 'DOMEventName@selector': 'event1',
// 'DOMEventName': 'event2'
// ...
// }
else {
on._listenerAttachers[ domEvtName ] = this._getDOMListenerAttacher( evtNameOrCallback );
}
}
// Set this property to be known that these attachers has already been created
// in the context of this particular View instance.
Object.defineProperty( on, '_listenerView', {
enumerable: false,
writable: true,
value: this
} );
}
// Repeat recursively for the children.
if ( def.children ) {
def.children.forEach( this._extendTemplateWithListenerAttachers, this );
}
}
}
utils.mix( View, DOMEmitterMixin );
const validSelectorTypes = new Set( [ 'string', 'boolean', 'function' ] );
/**
* Check whether region selector is valid.
*
* @ignore
* @private
* @param {*} selector Selector to be checked.
* @returns {Boolean}
*/
function isValidRegionSelector( selector ) {
return validSelectorTypes.has( typeof selector ) && selector !== false;
}
/**
* Normalizes given {@link core.ui.TemplateValueSchema} it's always in an Array–like format:
*
* { attributeName/text: 'bar' } ->
* { attributeName/text: [ 'bar' ] }
*
* { attributeName/text: { model: ..., modelAttributeName: ..., callback: ... } } ->
* { attributeName/text: [ { model: ..., modelAttributeName: ..., callback: ... } ] }
*
* { attributeName/text: [ 'bar', { model: ..., modelAttributeName: ... }, 'baz' ] }
*
* @ignore
* @private
* @param {core.ui.TemplateValueSchema} valueSchema
* @returns {Array}
*/
function normalizeBinderValueSchema( valueSchema ) {
return Array.isArray( valueSchema ) ? valueSchema : [ valueSchema ];
}
/**
* Checks whether given {@link core.ui.TemplateValueSchema} contains a
* {@link core.ui.ViewModelBinding}.
*
* @ignore
* @private
* @param {core.ui.TemplateValueSchema} valueSchema
* @returns {Boolean}
*/
function hasModelBinding( valueSchema ) {
if ( Array.isArray( valueSchema ) ) {
return valueSchema.some( hasModelBinding );
} else if ( valueSchema.model ) {
return true;
}
return false;
}
/**
* A helper which concatenates the value avoiding unwanted
* leading white spaces.
*
* @ignore
* @private
* @param {String} prev
* @param {String} cur
* @returns {String}
*/
function binderValueReducer( prev, cur ) {
return prev === '' ? `${cur}` : `${prev} ${cur}`;
}
/**
* Describes Model binding created by {@link View#attributeBinder}.
*
* @typedef core.ui.ViewModelBinding
* @type Object
* @property {Symbol} type
* @property {core.ui.Model} model
* @property {String} attribute
* @property {String} [valueIfTrue]
* @property {Function} [callback]
*/