| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699 |
- /**
- * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
- 'use strict';
- import Collection from '../collection.js';
- import Region from './region.js';
- import Template from './template.js';
- import CKEditorError from '../ckeditorerror.js';
- import DOMEmitterMixin from './domemittermixin.js';
- import utils from '../utils.js';
- import isPlainObject from '../lib/lodash/isPlainObject.js';
- const bindToSymbol = Symbol( 'bind-to' );
- const bindIfSymbol = Symbol( 'bind-if' );
- /**
- * 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.
- */
- constructor( model ) {
- /**
- * Model of this view.
- *
- * @type {core.ui.Model}
- */
- this.model = model || null;
- /**
- * Regions of this view. See {@link core.ui.View#register}.
- *
- * @type {Collection}
- */
- this.regions = new Collection( {
- idProperty: 'name'
- } );
- /**
- * Template of this view.
- *
- * @type {Object}
- */
- this.template = null;
- /**
- * Region selectors of this view. See {@link core.ui.View#register}.
- *
- * @private
- * @type {Object}
- */
- this._regionsSelectors = {};
- /**
- * Element of this view.
- *
- * @private
- * @type {HTMLElement}
- */
- this._element = null;
- /**
- * An instance of Template to generate {@link core.ui.View#_el}.
- *
- * @private
- * @type {Template}
- */
- this._template = null;
- }
- /**
- * Element of this view. The element is rendered on first reference
- * using {@link core.ui.View#template} definition and {@link core.ui.View#_template} object.
- */
- get element() {
- if ( this._element ) {
- return this._element;
- }
- 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._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
- */
- 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: [
- * // <p>...</p> 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: {
- * // <input checked> this.model.a is not undefined/null/false/''
- * // <input> this.model.a is undefined/null/false
- * checked: bind.if( 'a' )
- * },
- * children: [
- * {
- * // <input>"b-is-not-set"</input> when this.model.b is undefined/null/false/''
- * // <input></input> 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.
- */
- 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._regionsSelectors[ 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 id="first-div" class="my-div">Div text.</div>"
- *
- * 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._regionsSelectors = 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#_regionsSelectors} into {@link Region#init}.
- *
- * @protected
- */
- _initRegions() {
- let region, regionEl, regionSelector;
- for ( region of this.regions ) {
- regionSelector = this._regionsSelectors[ 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]
- */
|