view.js 12 KB

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