view.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402
  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 Model from '../model.js';
  8. import Region from './region.js';
  9. import Template from './template.js';
  10. import CKEditorError from '../ckeditorerror.js';
  11. import DOMEmitterMixin from './domemittermixin.js';
  12. import objectUtils from '../lib/lodash/object.js';
  13. /**
  14. * Basic View class.
  15. *
  16. * @class View
  17. * @extends Model
  18. * @mixins DOMEmitterMixin
  19. */
  20. export default class View extends Model {
  21. /**
  22. * Creates an instance of the {@link View} class.
  23. *
  24. * @param {Model} model (View)Model of this View.
  25. * @constructor
  26. */
  27. constructor( model ) {
  28. super();
  29. /**
  30. * Model of this view.
  31. *
  32. * @property {Model}
  33. */
  34. this.model = model || null;
  35. /**
  36. * Regions of this view. See {@link #register}.
  37. *
  38. * @property {Collection}
  39. */
  40. this.regions = new Collection( {
  41. idProperty: 'name'
  42. } );
  43. /**
  44. * Template of this view.
  45. *
  46. * @property {Object}
  47. */
  48. this.template = null;
  49. /**
  50. * Region selectors of this view. See {@link #register}.
  51. *
  52. * @private
  53. * @property {Object}
  54. */
  55. this._regionsSelectors = {};
  56. /**
  57. * Element of this view.
  58. *
  59. * @private
  60. * @property {HTMLElement}
  61. */
  62. this._el = null;
  63. /**
  64. * An instance of Template to generate {@link #_el}.
  65. *
  66. * @private
  67. * @property {Template}
  68. */
  69. this._template = null;
  70. }
  71. /**
  72. * Element of this view. The element is rendered on first reference
  73. * using {@link #template} definition and {@link #_template} object.
  74. *
  75. * @property el
  76. */
  77. get el() {
  78. if ( this._el ) {
  79. return this._el;
  80. }
  81. if ( !this.template ) {
  82. /**
  83. * Attempting to access an element of a view, which has no `template`
  84. * property.
  85. *
  86. * @error ui-view-notemplate
  87. */
  88. throw new CKEditorError( 'ui-view-notemplate' );
  89. }
  90. // Prepare pre–defined listeners.
  91. this._prepareElementListeners( this.template );
  92. this._template = new Template( this.template );
  93. return ( this._el = this._template.render() );
  94. }
  95. set el( el ) {
  96. this._el = el;
  97. }
  98. /**
  99. * Initializes the view.
  100. */
  101. init() {
  102. this._initRegions();
  103. }
  104. /**
  105. * Registers a region in {@link #regions}.
  106. *
  107. * let view = new View();
  108. *
  109. * // region.name == "foo", region.el == view.el.firstChild
  110. * view.register( 'foo', el => el.firstChild );
  111. *
  112. * // region.name == "bar", region.el == view.el.querySelector( 'span' )
  113. * view.register( new Region( 'bar' ), 'span' );
  114. *
  115. * // region.name == "bar", region.el == view.el.querySelector( '#div#id' )
  116. * view.register( 'bar', 'div#id', true );
  117. *
  118. * // region.name == "baz", region.el == null
  119. * view.register( 'baz', true );
  120. *
  121. * @param {String|Region} stringOrRegion The name or an instance of the Region
  122. * to be registered. If `String`, the region will be created on the fly.
  123. * @param {String|Function|true} regionSelector The selector to retrieve region's element
  124. * in DOM when the region instance is initialized (see {@link Region#init}, {@link #init}).
  125. * @param {Boolean} [override] When set `true` it will allow overriding of registered regions.
  126. */
  127. register( ...args ) {
  128. let region, regionName;
  129. if ( typeof args[ 0 ] === 'string' ) {
  130. regionName = args[ 0 ];
  131. region = this.regions.get( regionName ) || new Region( regionName );
  132. } else if ( args[ 0 ] instanceof Region ) {
  133. regionName = args[ 0 ].name;
  134. region = args[ 0 ];
  135. } else {
  136. /**
  137. * A name of the region or an instance of Region is required.
  138. *
  139. * @error ui-view-register-wrongtype
  140. */
  141. throw new CKEditorError( 'ui-view-register-wrongtype' );
  142. }
  143. const regionSelector = args[ 1 ];
  144. if ( !regionSelector || !isValidRegionSelector( regionSelector ) ) {
  145. /**
  146. * The selector must be String, Function or `true`.
  147. *
  148. * @error ui-view-register-badselector
  149. */
  150. throw new CKEditorError( 'ui-view-register-badselector' );
  151. }
  152. const registered = this.regions.get( regionName );
  153. if ( !registered ) {
  154. this.regions.add( region );
  155. } else {
  156. if ( registered !== region ) {
  157. if ( !args[ 2 ] ) {
  158. /**
  159. * Overriding is possible only when `override` flag is set.
  160. *
  161. * @error ui-view-register-override
  162. */
  163. throw new CKEditorError( 'ui-view-register-override' );
  164. }
  165. this.regions.remove( registered );
  166. this.regions.add( region );
  167. }
  168. }
  169. this._regionsSelectors[ regionName ] = regionSelector;
  170. }
  171. /**
  172. * Binds an `attribute` of View's model so the DOM of the View is updated when the `attribute`
  173. * changes. It returns a function which, once called in the context of a DOM element,
  174. * attaches a listener to the model which, in turn, brings changes to DOM.
  175. *
  176. * @param {String} attribute Attribute name in the model to be observed.
  177. * @param {Function} [callback] Callback function executed on attribute change in model.
  178. * If not specified, a default DOM `domUpdater` supplied by the template is used.
  179. */
  180. bindToAttribute( attribute, callback ) {
  181. /**
  182. * Attaches a listener to View's model, which updates DOM when the model's attribute
  183. * changes. DOM is either updated by the `domUpdater` function supplied by the template
  184. * (like attribute changer or `innerHTML` setter) or custom `callback` passed to {@link #bind}.
  185. *
  186. * This function is called by {@link Template#render}.
  187. *
  188. * @param {HTMLElement} el DOM element to be updated when `attribute` in model changes.
  189. * @param {Function} domUpdater A function provided by the template which updates corresponding
  190. * DOM.
  191. */
  192. return ( el, domUpdater ) => {
  193. let onModelChange;
  194. if ( callback ) {
  195. onModelChange = ( evt, value ) => {
  196. let processedValue = callback( el, value );
  197. if ( typeof processedValue != 'undefined' ) {
  198. domUpdater( el, processedValue );
  199. }
  200. };
  201. } else {
  202. onModelChange = ( evt, value ) => domUpdater( el, value );
  203. }
  204. // Execute callback when the attribute changes.
  205. this.listenTo( this.model, 'change:' + attribute, onModelChange );
  206. // Set the initial state of the view.
  207. onModelChange( null, this.model[ attribute ] );
  208. };
  209. }
  210. /**
  211. * Destroys the view instance. The process includes:
  212. * 1. Removal of child views from {@link #regions}.
  213. * 2. Destruction of the {@link #regions}.
  214. * 3. Removal of {#link #_el} from DOM.
  215. */
  216. destroy() {
  217. let childView;
  218. this.stopListening();
  219. for ( let region of this.regions ) {
  220. while ( ( childView = region.views.get( 0 ) ) ) {
  221. region.views.remove( childView );
  222. }
  223. this.regions.remove( region ).destroy();
  224. }
  225. if ( this.template ) {
  226. this.el.remove();
  227. }
  228. this.model = this.regions = this.template = this._regionsSelectors = this._el = this._template = null;
  229. }
  230. /**
  231. * Initializes {@link #regions} of this view by passing a DOM element
  232. * generated from {@link #_regionsSelectors} into {@link Region#init}.
  233. *
  234. * @protected
  235. */
  236. _initRegions() {
  237. let region, regionEl, regionSelector;
  238. for ( region of this.regions ) {
  239. regionSelector = this._regionsSelectors[ region.name ];
  240. if ( typeof regionSelector == 'string' ) {
  241. regionEl = this.el.querySelector( regionSelector );
  242. } else if ( typeof regionSelector == 'function' ) {
  243. regionEl = regionSelector( this.el );
  244. } else {
  245. regionEl = null;
  246. }
  247. region.init( regionEl );
  248. }
  249. }
  250. /**
  251. * For a given event name or callback, returns a function which,
  252. * once executed in a context of an element, attaches native DOM listener
  253. * to the element. The listener executes given callback or fires View's event
  254. * of given name.
  255. *
  256. * @protected
  257. * @param {String|Function} evtNameOrCallback Event name to be fired on View or callback to execute.
  258. * @returns {Function} A function to be executed in the context of an element.
  259. */
  260. _getDOMListenerAttacher( evtNameOrCallback ) {
  261. /**
  262. * Attaches a native DOM listener to given element. The listener executes the
  263. * callback or fires View's event.
  264. *
  265. * Note: If the selector is supplied, it narrows the scope to relevant targets only.
  266. * So instead of
  267. *
  268. * children: [
  269. * { tag: 'span', on: { click: 'foo' } }
  270. * { tag: 'span', on: { click: 'foo' } }
  271. * ]
  272. *
  273. * a single, more efficient listener can be attached that uses **event delegation**:
  274. *
  275. * children: [
  276. * { tag: 'span' }
  277. * { tag: 'span' }
  278. * ],
  279. * on: {
  280. * 'click@span': 'foo',
  281. * }
  282. *
  283. * @param {HTMLElement} el Element, to which the native DOM Event listener is attached.
  284. * @param {String} domEventName The name of native DOM Event.
  285. * @param {String} [selector] If provided, the selector narrows the scope to relevant targets only.
  286. */
  287. return ( el, domEvtName, selector ) => {
  288. // Use View's listenTo, so the listener is detached, when the View dies.
  289. this.listenTo( el, domEvtName, ( evt, domEvt ) => {
  290. if ( !selector || domEvt.target.matches( selector ) ) {
  291. if ( typeof evtNameOrCallback == 'function' ) {
  292. evtNameOrCallback( domEvt );
  293. } else {
  294. this.fire( evtNameOrCallback, domEvt );
  295. }
  296. }
  297. } );
  298. };
  299. }
  300. /**
  301. * Iterates over "on" property in {@link template} definition to recursively
  302. * replace each listener declaration with a function which, once executed in a context
  303. * of an element, attaches native DOM listener to the element.
  304. *
  305. * @protected
  306. * @param {TemplateDefinition} def Template definition.
  307. */
  308. _prepareElementListeners( def ) {
  309. let on = def.on;
  310. if ( on ) {
  311. let domEvtName, evtNameOrCallback;
  312. for ( domEvtName in on ) {
  313. evtNameOrCallback = on[ domEvtName ];
  314. // Listeners allow definition with an array:
  315. //
  316. // on: {
  317. // 'DOMEventName@selector': [ 'event1', callback ],
  318. // 'DOMEventName': [ callback, 'event2', 'event3' ]
  319. // ...
  320. // }
  321. if ( Array.isArray( evtNameOrCallback ) ) {
  322. on[ domEvtName ] = on[ domEvtName ].map( this._getDOMListenerAttacher, this );
  323. }
  324. // Listeners allow definition with a string containing event name:
  325. //
  326. // on: {
  327. // 'DOMEventName@selector': 'event1',
  328. // 'DOMEventName': 'event2'
  329. // ...
  330. // }
  331. else {
  332. on[ domEvtName ] = this._getDOMListenerAttacher( evtNameOrCallback );
  333. }
  334. }
  335. }
  336. // Repeat recursively for the children.
  337. if ( def.children ) {
  338. def.children.map( this._prepareElementListeners, this );
  339. }
  340. }
  341. }
  342. objectUtils.extend( View.prototype, DOMEmitterMixin );
  343. const validSelectorTypes = new Set( [ 'string', 'boolean', 'function' ] );
  344. /**
  345. * Check whether region selector is valid.
  346. *
  347. * @private
  348. * @param {*} selector Selector to be checked.
  349. * @returns {Boolean}
  350. */
  351. function isValidRegionSelector( selector ) {
  352. return validSelectorTypes.has( typeof selector ) && selector !== false;
  353. }