view.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699
  1. /**
  2. * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md.
  4. */
  5. 'use strict';
  6. import Collection from '../collection.js';
  7. import Region from './region.js';
  8. import Template from './template.js';
  9. import CKEditorError from '../ckeditorerror.js';
  10. import DOMEmitterMixin from './domemittermixin.js';
  11. import utils from '../utils.js';
  12. import isPlainObject from '../lib/lodash/isPlainObject.js';
  13. const bindToSymbol = Symbol( 'bind-to' );
  14. const bindIfSymbol = Symbol( 'bind-if' );
  15. /**
  16. * Basic View class.
  17. *
  18. * @memberOf core.ui
  19. * @mixes DOMEmitterMixin
  20. */
  21. export default class View {
  22. /**
  23. * Creates an instance of the {@link View} class.
  24. *
  25. * @param {core.ui.Model} model (View)Model of this View.
  26. */
  27. constructor( model ) {
  28. /**
  29. * Model of this view.
  30. *
  31. * @type {core.ui.Model}
  32. */
  33. this.model = model || null;
  34. /**
  35. * Regions of this view. See {@link core.ui.View#register}.
  36. *
  37. * @type {Collection}
  38. */
  39. this.regions = new Collection( {
  40. idProperty: 'name'
  41. } );
  42. /**
  43. * Template of this view.
  44. *
  45. * @type {Object}
  46. */
  47. this.template = null;
  48. /**
  49. * Region selectors of this view. See {@link core.ui.View#register}.
  50. *
  51. * @private
  52. * @type {Object}
  53. */
  54. this._regionsSelectors = {};
  55. /**
  56. * Element of this view.
  57. *
  58. * @private
  59. * @type {HTMLElement}
  60. */
  61. this._element = null;
  62. /**
  63. * An instance of Template to generate {@link core.ui.View#_el}.
  64. *
  65. * @private
  66. * @type {Template}
  67. */
  68. this._template = null;
  69. }
  70. /**
  71. * Element of this view. The element is rendered on first reference
  72. * using {@link core.ui.View#template} definition and {@link core.ui.View#_template} object.
  73. */
  74. get element() {
  75. if ( this._element ) {
  76. return this._element;
  77. }
  78. if ( !this.template ) {
  79. /**
  80. * Attempting to access an element of a view, which has no `template`
  81. * property.
  82. *
  83. * @error ui-view-notemplate
  84. */
  85. throw new CKEditorError( 'ui-view-notemplate' );
  86. }
  87. // Prepare pre–defined listeners.
  88. this._extendTemplateWithListenerAttachers( this.template );
  89. // Prepare pre–defined attribute bindings.
  90. this._extendTemplateWithModelBinders( this.template );
  91. this._template = new Template( this.template );
  92. return ( this._element = this._template.render() );
  93. }
  94. set element( el ) {
  95. this._element = el;
  96. }
  97. /**
  98. * And entry point to the interface which allows binding attributes of {@link View#model}
  99. * to the DOM items like HTMLElement attributes or Text Node `textContent`, so their state
  100. * is synchronized with {@link View#model}.
  101. *
  102. * @readonly
  103. */
  104. get attributeBinder() {
  105. if ( this._attributeBinder ) {
  106. return this._attributeBinder;
  107. }
  108. const model = this.model;
  109. const binder = {
  110. /**
  111. * Binds {@link View#model} to HTMLElement attribute or Text Node `textContent`
  112. * so remains in sync with the Model when it changes.
  113. *
  114. * this.template = {
  115. * tag: 'p',
  116. * attributes: {
  117. * // class="..." attribute gets bound to this.model.a
  118. * 'class': bind.to( 'a' )
  119. * },
  120. * children: [
  121. * // <p>...</p> gets bound to this.model.b; always `toUpperCase()`.
  122. * { text: bind.to( 'b', ( value, node ) => value.toUpperCase() ) }
  123. * ]
  124. * }
  125. *
  126. * @property {attributeBinder.to}
  127. * @param {String} attribute Name of {@link View#model} used in the binding.
  128. * @param {Function} [callback] Allows processing of the value. Accepts `Node` and `value` as arguments.
  129. * @return {core.ui.ViewModelBinding}
  130. */
  131. to( attribute, callback ) {
  132. return {
  133. type: bindToSymbol,
  134. model: model,
  135. attribute,
  136. callback
  137. };
  138. },
  139. /**
  140. * Binds {@link View#model} to HTMLElement attribute or Text Node `textContent`
  141. * so remains in sync with the Model when it changes. Unlike {@link View#attributeBinder.to},
  142. * it controls the presence of the attribute/`textContent` depending on the "falseness" of
  143. * {@link View#model} attribute.
  144. *
  145. * this.template = {
  146. * tag: 'input',
  147. * attributes: {
  148. * // <input checked> this.model.a is not undefined/null/false/''
  149. * // <input> this.model.a is undefined/null/false
  150. * checked: bind.if( 'a' )
  151. * },
  152. * children: [
  153. * {
  154. * // <input>"b-is-not-set"</input> when this.model.b is undefined/null/false/''
  155. * // <input></input> when this.model.b is not "falsy"
  156. * text: bind.if( 'b', 'b-is-not-set', ( value, node ) => !value )
  157. * }
  158. * ]
  159. * }
  160. *
  161. * @property {attributeBinder.if}
  162. * @param {String} attribute Name of {@link View#model} used in the binding.
  163. * @param {String} [valueIfTrue] Value set when {@link View#model} attribute is not undefined/null/false/''.
  164. * @param {Function} [callback] Allows processing of the value. Accepts `Node` and `value` as arguments.
  165. * @return {core.ui.ViewModelBinding}
  166. */
  167. if( attribute, valueIfTrue, callback ) {
  168. return {
  169. type: bindIfSymbol,
  170. model: model,
  171. attribute,
  172. valueIfTrue,
  173. callback
  174. };
  175. }
  176. };
  177. return ( this._attributeBinder = binder );
  178. }
  179. /**
  180. * Initializes the view.
  181. */
  182. init() {
  183. this._initRegions();
  184. }
  185. /**
  186. * Registers a region in {@link core.ui.View#regions}.
  187. *
  188. * let view = new View();
  189. *
  190. * // region.name == "foo", region.element == view.element.firstChild
  191. * view.register( 'foo', el => el.firstChild );
  192. *
  193. * // region.name == "bar", region.element == view.element.querySelector( 'span' )
  194. * view.register( new Region( 'bar' ), 'span' );
  195. *
  196. * // region.name == "bar", region.element == view.element.querySelector( '#div#id' )
  197. * view.register( 'bar', 'div#id', true );
  198. *
  199. * // region.name == "baz", region.element == null
  200. * view.register( 'baz', true );
  201. *
  202. * @param {String|Region} stringOrRegion The name or an instance of the Region
  203. * to be registered. If `String`, the region will be created on the fly.
  204. * @param {String|Function|true} regionSelector The selector to retrieve region's element
  205. * in DOM when the region instance is initialized (see {@link Region#init}, {@link core.ui.View#init}).
  206. * @param {Boolean} [override] When set `true` it will allow overriding of registered regions.
  207. */
  208. register( ...args ) {
  209. let region, regionName;
  210. if ( typeof args[ 0 ] === 'string' ) {
  211. regionName = args[ 0 ];
  212. region = this.regions.get( regionName ) || new Region( regionName );
  213. } else if ( args[ 0 ] instanceof Region ) {
  214. regionName = args[ 0 ].name;
  215. region = args[ 0 ];
  216. } else {
  217. /**
  218. * A name of the region or an instance of Region is required.
  219. *
  220. * @error ui-view-register-wrongtype
  221. */
  222. throw new CKEditorError( 'ui-view-register-wrongtype' );
  223. }
  224. const regionSelector = args[ 1 ];
  225. if ( !regionSelector || !isValidRegionSelector( regionSelector ) ) {
  226. /**
  227. * The selector must be String, Function or `true`.
  228. *
  229. * @error ui-view-register-badselector
  230. */
  231. throw new CKEditorError( 'ui-view-register-badselector' );
  232. }
  233. const registered = this.regions.get( regionName );
  234. if ( !registered ) {
  235. this.regions.add( region );
  236. } else {
  237. if ( registered !== region ) {
  238. if ( !args[ 2 ] ) {
  239. /**
  240. * Overriding is possible only when `override` flag is set.
  241. *
  242. * @error ui-view-register-override
  243. */
  244. throw new CKEditorError( 'ui-view-register-override' );
  245. }
  246. this.regions.remove( registered );
  247. this.regions.add( region );
  248. }
  249. }
  250. this._regionsSelectors[ regionName ] = regionSelector;
  251. }
  252. /**
  253. * Applies template to existing DOM element in the context of a View.
  254. *
  255. * const element = document.createElement( 'div' );
  256. * const view = new View( new Model( { divClass: 'my-div' } ) );
  257. *
  258. * view.applyTemplateToElement( element, {
  259. * attrs: {
  260. * id: 'first-div',
  261. * class: view.bindToAttribute( 'divClass' )
  262. * },
  263. * on: {
  264. * click: 'elementClicked' // Will be fired by the View instance.
  265. * }
  266. * children: [
  267. * 'Div text.'
  268. * ]
  269. * } );
  270. *
  271. * element.outerHTML == "<div id="first-div" class="my-div">Div text.</div>"
  272. *
  273. * See: {@link Template#apply}.
  274. *
  275. * @param {DOMElement} element DOM Element to initialize.
  276. * @param {core.ui.TemplateDefinition} def Template definition to be applied.
  277. */
  278. applyTemplateToElement( element, def ) {
  279. // Prepare pre–defined listeners.
  280. this._extendTemplateWithListenerAttachers( def );
  281. // Prepare pre–defined attribute bindings.
  282. this._extendTemplateWithModelBinders( def );
  283. new Template( def ).apply( element );
  284. }
  285. /**
  286. * Destroys the view instance. The process includes:
  287. *
  288. * 1. Removal of child views from {@link core.ui.View#regions}.
  289. * 2. Destruction of the {@link core.ui.View#regions}.
  290. * 3. Removal of {#link #_el} from DOM.
  291. */
  292. destroy() {
  293. let childView;
  294. this.stopListening();
  295. for ( let region of this.regions ) {
  296. while ( ( childView = region.views.get( 0 ) ) ) {
  297. region.views.remove( childView );
  298. }
  299. this.regions.remove( region ).destroy();
  300. }
  301. if ( this.template ) {
  302. this.element.remove();
  303. }
  304. this.model = this.regions = this.template = this._regionsSelectors = this._element = this._template = null;
  305. }
  306. /**
  307. * Initializes {@link core.ui.View#regions} of this view by passing a DOM element
  308. * generated from {@link core.ui.View#_regionsSelectors} into {@link Region#init}.
  309. *
  310. * @protected
  311. */
  312. _initRegions() {
  313. let region, regionEl, regionSelector;
  314. for ( region of this.regions ) {
  315. regionSelector = this._regionsSelectors[ region.name ];
  316. if ( typeof regionSelector == 'string' ) {
  317. regionEl = this.element.querySelector( regionSelector );
  318. } else if ( typeof regionSelector == 'function' ) {
  319. regionEl = regionSelector( this.element );
  320. } else {
  321. regionEl = null;
  322. }
  323. region.init( regionEl );
  324. }
  325. }
  326. /**
  327. * For a given event name or callback, returns a function which,
  328. * once executed in a context of an element, attaches native DOM listener
  329. * to the element. The listener executes given callback or fires View's event
  330. * of given name.
  331. *
  332. * @protected
  333. * @param {String|Function} evtNameOrCallback Event name to be fired on View or callback to execute.
  334. * @returns {Function} A listener attacher function to be executed in the context of an element.
  335. */
  336. _getDOMListenerAttacher( evtNameOrCallback ) {
  337. /**
  338. * Attaches a native DOM listener to given element. The listener executes the
  339. * callback or fires View's event.
  340. *
  341. * Note: If the selector is supplied, it narrows the scope to relevant targets only.
  342. * So instead of
  343. *
  344. * children: [
  345. * { tag: 'span', on: { click: 'foo' } }
  346. * { tag: 'span', on: { click: 'foo' } }
  347. * ]
  348. *
  349. * a single, more efficient listener can be attached that uses **event delegation**:
  350. *
  351. * children: [
  352. * { tag: 'span' }
  353. * { tag: 'span' }
  354. * ],
  355. * on: {
  356. * 'click@span': 'foo',
  357. * }
  358. *
  359. * @param {HTMLElement} el Element, to which the native DOM Event listener is attached.
  360. * @param {String} domEventName The name of native DOM Event.
  361. * @param {String} [selector] If provided, the selector narrows the scope to relevant targets only.
  362. */
  363. return ( el, domEvtName, selector ) => {
  364. // Use View's listenTo, so the listener is detached, when the View dies.
  365. this.listenTo( el, domEvtName, ( evt, domEvt ) => {
  366. if ( !selector || domEvt.target.matches( selector ) ) {
  367. if ( typeof evtNameOrCallback == 'function' ) {
  368. evtNameOrCallback( domEvt );
  369. } else {
  370. this.fire( evtNameOrCallback, domEvt );
  371. }
  372. }
  373. } );
  374. };
  375. }
  376. /**
  377. * For given {@link core.ui.TemplateValueSchema} found by (@link _extendTemplateWithModelBinders} containing
  378. * {@link core.ui.ViewModelBinding} it returns a function, which when called by {@link Template#render}
  379. * or {@link Template#apply} activates the binding and sets its initial value.
  380. *
  381. * Note: {@link core.ui.TemplateValueSchema} can be for HTMLElement attributes or Text Node `textContent`.
  382. *
  383. * @protected
  384. * @param {core.ui.TemplateValueSchema}
  385. * @return {Function}
  386. */
  387. _getModelBinder( valueSchema ) {
  388. valueSchema = normalizeBinderValueSchema( valueSchema );
  389. /**
  390. * Assembles the value using {@link core.ui.TemplateValueSchema} and stores it in a form of
  391. * an Array. Each entry of an Array corresponds to one of {@link core.ui.TemplateValueSchema}
  392. * items.
  393. *
  394. * @private
  395. * @param {Node} node
  396. * @return {Array}
  397. */
  398. const getBoundValue = ( node ) => {
  399. let model, modelValue;
  400. return valueSchema.map( schemaItem => {
  401. model = schemaItem.model;
  402. if ( model ) {
  403. modelValue = model[ schemaItem.attribute ];
  404. if ( schemaItem.callback ) {
  405. modelValue = schemaItem.callback( modelValue, node );
  406. }
  407. return modelValue;
  408. } else {
  409. return schemaItem;
  410. }
  411. } );
  412. };
  413. /**
  414. * Attaches a listener to {@link View#model}, which updates DOM with a value constructed from
  415. * {@link core.ui.TemplateValueSchema} when {@link View#model} attribute value changes.
  416. *
  417. * This function is called by {@link Template#render} or {@link Template#apply}.
  418. *
  419. * @param {Node} node DOM Node to be updated when {@link View#model} changes.
  420. * @param {Function} domUpdater A function provided by {@link Template} which updates corresponding
  421. * DOM attribute or `textContent`.
  422. */
  423. return ( node, domUpdater ) => {
  424. // Check if valueSchema is a single bind.if, like:
  425. // { class: bind.if( 'foo' ) }
  426. const isPlainBindIf = valueSchema.length == 1 && valueSchema[ 0 ].type == bindIfSymbol;
  427. // A function executed each time bound model attribute changes.
  428. const onModelChange = () => {
  429. let value = getBoundValue( node );
  430. if ( isPlainBindIf ) {
  431. value = value[ 0 ];
  432. } else {
  433. value = value.reduce( binderValueReducer, '' );
  434. }
  435. const isSet = isPlainBindIf ? !!value : value;
  436. const valueToSet = isPlainBindIf ?
  437. ( valueSchema[ 0 ].valueIfTrue || '' ) : value;
  438. if ( isSet ) {
  439. domUpdater.set( valueToSet );
  440. } else {
  441. domUpdater.remove();
  442. }
  443. };
  444. valueSchema
  445. .filter( schemaItem => schemaItem.model )
  446. .forEach( schemaItem => {
  447. this.listenTo( schemaItem.model, 'change:' + schemaItem.attribute, onModelChange );
  448. } );
  449. // Set initial values.
  450. onModelChange();
  451. };
  452. }
  453. /**
  454. * Iterates over "attributes" and "text" properties in {@link TemplateDefinition} and
  455. * locates existing {@link core.ui.ViewModelBinding} created by {@link core.ui.View#attributeBinder}.
  456. * Then, for each such a binding, it creates corresponding entry in {@link Template#_modelBinders},
  457. * which can be then activated by {@link Template#render} or {@link Template#apply}.
  458. *
  459. * @protected
  460. * @param {core.ui.TemplateDefinition} def
  461. */
  462. _extendTemplateWithModelBinders( def ) {
  463. const attributes = def.attributes;
  464. const text = def.text;
  465. let binders = def._modelBinders;
  466. let attrName, attrValue;
  467. if ( !binders && isPlainObject( def ) ) {
  468. Object.defineProperty( def, '_modelBinders', {
  469. enumerable: false,
  470. writable: true,
  471. value: {
  472. attributes: {}
  473. }
  474. } );
  475. binders = def._modelBinders;
  476. }
  477. if ( attributes ) {
  478. for ( attrName in attributes ) {
  479. attrValue = attributes[ attrName ];
  480. if ( hasModelBinding( attrValue ) ) {
  481. binders.attributes[ attrName ] = this._getModelBinder( attrValue );
  482. }
  483. }
  484. }
  485. if ( text && hasModelBinding( text ) ) {
  486. binders.text = this._getModelBinder( text );
  487. }
  488. // Repeat recursively for the children.
  489. if ( def.children ) {
  490. def.children.forEach( this._extendTemplateWithModelBinders, this );
  491. }
  492. }
  493. /**
  494. * Iterates over "on" property in {@link TemplateDefinition} to recursively
  495. * replace each listener declaration with a function which, once executed in a context
  496. * of an element, attaches native DOM listener to that element.
  497. *
  498. * @protected
  499. * @param {core.ui.TemplateDefinition} def Template definition.
  500. */
  501. _extendTemplateWithListenerAttachers( def ) {
  502. const on = def.on;
  503. // Don't create attachers if they're already here or in the context of the same (this) View instance.
  504. if ( on && ( !on._listenerAttachers || on._listenerView != this ) ) {
  505. let domEvtName, evtNameOrCallback;
  506. Object.defineProperty( on, '_listenerAttachers', {
  507. enumerable: false,
  508. writable: true,
  509. value: {}
  510. } );
  511. for ( domEvtName in on ) {
  512. evtNameOrCallback = on[ domEvtName ];
  513. // Listeners allow definition with an array:
  514. //
  515. // on: {
  516. // 'DOMEventName@selector': [ 'event1', callback ],
  517. // 'DOMEventName': [ callback, 'event2', 'event3' ]
  518. // ...
  519. // }
  520. if ( Array.isArray( evtNameOrCallback ) ) {
  521. on._listenerAttachers[ domEvtName ] = on[ domEvtName ].map( this._getDOMListenerAttacher, this );
  522. }
  523. // Listeners allow definition with a string containing event name:
  524. //
  525. // on: {
  526. // 'DOMEventName@selector': 'event1',
  527. // 'DOMEventName': 'event2'
  528. // ...
  529. // }
  530. else {
  531. on._listenerAttachers[ domEvtName ] = this._getDOMListenerAttacher( evtNameOrCallback );
  532. }
  533. }
  534. // Set this property to be known that these attachers has already been created
  535. // in the context of this particular View instance.
  536. Object.defineProperty( on, '_listenerView', {
  537. enumerable: false,
  538. writable: true,
  539. value: this
  540. } );
  541. }
  542. // Repeat recursively for the children.
  543. if ( def.children ) {
  544. def.children.forEach( this._extendTemplateWithListenerAttachers, this );
  545. }
  546. }
  547. }
  548. utils.mix( View, DOMEmitterMixin );
  549. const validSelectorTypes = new Set( [ 'string', 'boolean', 'function' ] );
  550. /**
  551. * Check whether region selector is valid.
  552. *
  553. * @ignore
  554. * @private
  555. * @param {*} selector Selector to be checked.
  556. * @returns {Boolean}
  557. */
  558. function isValidRegionSelector( selector ) {
  559. return validSelectorTypes.has( typeof selector ) && selector !== false;
  560. }
  561. /**
  562. * Normalizes given {@link core.ui.TemplateValueSchema} it's always in an Array–like format:
  563. *
  564. * { attributeName/text: 'bar' } ->
  565. * { attributeName/text: [ 'bar' ] }
  566. *
  567. * { attributeName/text: { model: ..., modelAttributeName: ..., callback: ... } } ->
  568. * { attributeName/text: [ { model: ..., modelAttributeName: ..., callback: ... } ] }
  569. *
  570. * { attributeName/text: [ 'bar', { model: ..., modelAttributeName: ... }, 'baz' ] }
  571. *
  572. * @ignore
  573. * @private
  574. * @param {core.ui.TemplateValueSchema} valueSchema
  575. * @returns {Array}
  576. */
  577. function normalizeBinderValueSchema( valueSchema ) {
  578. return Array.isArray( valueSchema ) ? valueSchema : [ valueSchema ];
  579. }
  580. /**
  581. * Checks whether given {@link core.ui.TemplateValueSchema} contains a
  582. * {@link core.ui.ViewModelBinding}.
  583. *
  584. * @ignore
  585. * @private
  586. * @param {core.ui.TemplateValueSchema} valueSchema
  587. * @returns {Boolean}
  588. */
  589. function hasModelBinding( valueSchema ) {
  590. if ( Array.isArray( valueSchema ) ) {
  591. return valueSchema.some( hasModelBinding );
  592. } else if ( valueSchema.model ) {
  593. return true;
  594. }
  595. return false;
  596. }
  597. /**
  598. * A helper which concatenates the value avoiding unwanted
  599. * leading white spaces.
  600. *
  601. * @ignore
  602. * @private
  603. * @param {String} prev
  604. * @param {String} cur
  605. * @returns {String}
  606. */
  607. function binderValueReducer( prev, cur ) {
  608. return prev === '' ? `${cur}` : `${prev} ${cur}`;
  609. }
  610. /**
  611. * Describes Model binding created by {@link View#attributeBinder}.
  612. *
  613. * @typedef core.ui.ViewModelBinding
  614. * @type Object
  615. * @property {Symbol} type
  616. * @property {core.ui.Model} model
  617. * @property {String} attribute
  618. * @property {String} [valueIfTrue]
  619. * @property {Function} [callback]
  620. */