viewcollection.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362
  1. /**
  2. * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md.
  4. */
  5. /**
  6. * @module ui/viewcollection
  7. */
  8. import CKEditorError from '../utils/ckeditorerror.js';
  9. import ObservableMixin from '../utils/observablemixin.js';
  10. import Collection from '../utils/collection.js';
  11. import mix from '../utils/mix.js';
  12. import View from './view.js';
  13. /**
  14. * Collects {@link module:ui/view~View} instances.
  15. *
  16. * @extends module:utils/collection~Collection
  17. * @mixes module:utils/observablemixin~ObservableMixin
  18. */
  19. export default class ViewCollection extends Collection {
  20. /**
  21. * Creates a new {@link module:ui/viewcollection~ViewCollection} instance.
  22. *
  23. * @param {module:utils/locale~Locale} [locale] The {@link module:core/editor~Editor editor's locale} instance.
  24. */
  25. constructor( locale ) {
  26. super( {
  27. // An #id Number attribute should be legal and not break the `ViewCollection` instance.
  28. // https://github.com/ckeditor/ckeditor5-ui/issues/93
  29. idProperty: 'viewUid'
  30. } );
  31. // Handle {@link module:ui/view~View#element} in DOM when a new view is added to the collection.
  32. this.on( 'add', ( evt, view, index ) => {
  33. if ( this.ready && view.element && this._parentElement ) {
  34. this._parentElement.insertBefore( view.element, this._parentElement.children[ index ] );
  35. }
  36. } );
  37. // Handle {@link module:ui/view~View#element} in DOM when a view is removed from the collection.
  38. this.on( 'remove', ( evt, view ) => {
  39. if ( this.ready && view.element && this._parentElement ) {
  40. view.element.remove();
  41. }
  42. } );
  43. /**
  44. * The {@link module:core/editor~Editor editor's locale} instance.
  45. *
  46. * @member {module:utils/locale~Locale}
  47. */
  48. this.locale = locale;
  49. /**
  50. * Set to `true` once the parent's {@link module:ui/view~View#ready} is true, which means
  51. * that all the views in the collection are also ready (which can be asynchronous).
  52. *
  53. * @readonly
  54. * @observable
  55. * @member {Boolean} #ready
  56. */
  57. this.set( 'ready', false );
  58. /**
  59. * A parent element within which child views are rendered and managed in DOM.
  60. *
  61. * @protected
  62. * @member {HTMLElement}
  63. */
  64. this._parentElement = null;
  65. /**
  66. * A helper mapping between bound collection items passed to {@link #bindTo}
  67. * and view instances. Speeds up the view management.
  68. *
  69. * @protected
  70. * @member {HTMLElement}
  71. */
  72. this._boundItemsToViewsMap = new Map();
  73. }
  74. /**
  75. * Initializes child views by injecting {@link module:ui/view~View#element} into DOM
  76. * and calling {@link module:ui/view~View#init}.
  77. *
  78. * @returns {Promise} A Promise resolved when the initialization process is finished.
  79. */
  80. init() {
  81. if ( this.ready ) {
  82. /**
  83. * This ViewCollection has already been initialized.
  84. *
  85. * @error ui-viewcollection-init-reinit
  86. */
  87. throw new CKEditorError( 'ui-viewcollection-init-reinit: This ViewCollection has already been initialized.' );
  88. }
  89. const promises = [];
  90. for ( let view of this ) {
  91. // Do not render unbound children. They're already in DOM by explicit declaration
  92. // in Template definition.
  93. if ( this._parentElement && view.element ) {
  94. this._parentElement.appendChild( view.element );
  95. }
  96. promises.push( view.init() );
  97. }
  98. return Promise.all( promises ).then( () => {
  99. this.ready = true;
  100. } );
  101. }
  102. /**
  103. * Destroys the view collection along with child views.
  104. *
  105. * @returns {Promise} A Promise resolved when the destruction process is finished.
  106. */
  107. destroy() {
  108. let promises = [];
  109. for ( let view of this ) {
  110. promises.push( view.destroy() );
  111. }
  112. return Promise.all( promises );
  113. }
  114. /**
  115. * Adds a child view to the collection. If {@link module:ui/viewcollection~ViewCollection#ready}, the child view
  116. * is also initialized when added.
  117. *
  118. * @param {module:ui/view~View} view A child view.
  119. * @param {Number} [index] Index at which the child will be added to the collection.
  120. * @returns {Promise} A Promise resolved when the child {@link module:ui/view~View#init} is done.
  121. */
  122. add( view, index ) {
  123. super.add( view, index );
  124. // {@link module:ui/view~View#init} returns `Promise`.
  125. let promise = Promise.resolve();
  126. if ( this.ready && !view.ready ) {
  127. promise = promise.then( () => {
  128. return view.init();
  129. } );
  130. }
  131. return promise;
  132. }
  133. /**
  134. * Sets {@link #_parentElement} of this collection.
  135. *
  136. * @param {HTMLElement} element A new parent.
  137. */
  138. setParent( elementOrDocFragment ) {
  139. this._parentElement = elementOrDocFragment;
  140. }
  141. /**
  142. * Binds this collection to {@link module:utils/collection~Collection another collection}. For each item in the
  143. * second collection there will be one view instance added to this collection.
  144. *
  145. * The process can be automatic:
  146. *
  147. * // This collection stores items.
  148. * const items = new Collection( { idProperty: 'label' } );
  149. *
  150. * // This view collection will become a factory out of the collection of items.
  151. * const views = new ViewCollection( locale );
  152. *
  153. * // Activate the binding – since now, this view collection works like a **factory**.
  154. * // Each new item is passed to the FooView constructor like new FooView( locale, item ).
  155. * views.bindTo( items ).as( FooView );
  156. *
  157. * // As new items arrive to the collection, each becomes an instance of FooView
  158. * // in the view collection.
  159. * items.add( new Model( { label: 'foo' } ) );
  160. * items.add( new Model( { label: 'bar' } ) );
  161. *
  162. * console.log( views.length == 2 );
  163. *
  164. * // View collection is updated as the model is removed.
  165. * items.remove( 0 );
  166. * console.log( views.length == 1 );
  167. *
  168. * or the factory can be driven by a custom callback:
  169. *
  170. * // This collection stores any kind of data.
  171. * const data = new Collection();
  172. *
  173. * // This view collection will become a custom factory for the data.
  174. * const views = new ViewCollection( locale );
  175. *
  176. * // Activate the binding – the **factory** is driven by a custom callback.
  177. * views.bindTo( data ).as( item => {
  178. * if ( !item.foo ) {
  179. * return null;
  180. * } else if ( item.foo == 'bar' ) {
  181. * return new BarView();
  182. * } else {
  183. * return new DifferentView();
  184. * }
  185. * } );
  186. *
  187. * // As new data arrive to the collection, each is handled individually by the callback.
  188. * // This will produce BarView.
  189. * data.add( { foo: 'bar' } );
  190. *
  191. * // And this one will become DifferentView.
  192. * data.add( { foo: 'baz' } );
  193. *
  194. * // Also there will be no view for data lacking the `foo` property.
  195. * data.add( {} );
  196. *
  197. * console.log( controllers.length == 2 );
  198. *
  199. * // View collection is also updated as the data is removed.
  200. * data.remove( 0 );
  201. * console.log( controllers.length == 1 );
  202. *
  203. * @param {module:utils/collection~Collection} collection A collection to be bound.
  204. * @returns {module:ui/viewcollection~ViewCollection#bindTo#as}
  205. */
  206. bindTo( collection ) {
  207. return {
  208. /**
  209. * Determines the output view of the binding.
  210. *
  211. * @static
  212. * @param {Function|module:ui/view~View} CallbackOrViewClass Specifies the constructor of the view to be used or
  213. * a custom callback function which produces views.
  214. */
  215. as: ( CallbackOrViewClass ) => {
  216. let createView;
  217. if ( CallbackOrViewClass.prototype instanceof View ) {
  218. createView = ( item ) => {
  219. const viewInstance = new CallbackOrViewClass( this.locale, item );
  220. this._boundItemsToViewsMap.set( item, viewInstance );
  221. return viewInstance;
  222. };
  223. } else {
  224. createView = ( item ) => {
  225. const viewInstance = CallbackOrViewClass( item );
  226. this._boundItemsToViewsMap.set( item, viewInstance );
  227. return viewInstance;
  228. };
  229. }
  230. // Load the initial content of the collection.
  231. for ( let item of collection ) {
  232. this.add( createView( item ) );
  233. }
  234. // Synchronize views as new items are added to the collection.
  235. this.listenTo( collection, 'add', ( evt, item, index ) => {
  236. this.add( createView( item ), index );
  237. } );
  238. // Synchronize views as items are removed from the collection.
  239. this.listenTo( collection, 'remove', ( evt, item ) => {
  240. this.remove( this._boundItemsToViewsMap.get( item ) );
  241. this._boundItemsToViewsMap.delete( item );
  242. } );
  243. }
  244. };
  245. }
  246. /**
  247. * Delegates selected events coming from within the collection to desired {@link module:utils/emittermixin~EmitterMixin}.
  248. *
  249. * For instance:
  250. *
  251. * const viewA = new View();
  252. * const viewB = new View();
  253. * const viewC = new View();
  254. *
  255. * const views = parentView.createCollection();
  256. *
  257. * views.delegate( 'eventX' ).to( viewB );
  258. * views.delegate( 'eventX', 'eventY' ).to( viewC );
  259. *
  260. * views.add( viewA );
  261. *
  262. * then `eventX` is delegated (fired by) `viewB` and `viewC` along with `customData`:
  263. *
  264. * viewA.fire( 'eventX', customData );
  265. *
  266. * and `eventY` is delegated (fired by) `viewC` along with `customData`:
  267. *
  268. * viewA.fire( 'eventY', customData );
  269. *
  270. * See {@link module:utils/emittermixin~EmitterMixin#delegate}.
  271. *
  272. * @param {...String} events {@link module:ui/view~View} event names to be delegated to another {@link
  273. * module:utils/emittermixin~EmitterMixin}.
  274. * @returns {module:ui/viewcollection~ViewCollection#delegate.to}
  275. */
  276. delegate( ...events ) {
  277. if ( !events.length || !isStringArray( events ) ) {
  278. /**
  279. * All event names must be strings.
  280. *
  281. * @error ui-viewcollection-delegate-wrong-events
  282. */
  283. throw new CKEditorError( 'ui-viewcollection-delegate-wrong-events: All event names must be strings.' );
  284. }
  285. return {
  286. /**
  287. * Selects destination for {@link module:utils/emittermixin~EmitterMixin#delegate} events.
  288. *
  289. * @memberOf module:ui/viewcollection~ViewCollection#delegate
  290. * @function module:ui/viewcollection~ViewCollection#delegate.to
  291. * @param {module:utils/emittermixin~EmitterMixin} dest An `EmitterMixin` instance which is the destination for delegated events.
  292. */
  293. to: ( dest ) => {
  294. // Activate delegating on existing views in this collection.
  295. for ( let view of this ) {
  296. for ( let evtName of events ) {
  297. view.delegate( evtName ).to( dest );
  298. }
  299. }
  300. // Activate delegating on future views in this collection.
  301. this.on( 'add', ( evt, view ) => {
  302. for ( let evtName of events ) {
  303. view.delegate( evtName ).to( dest );
  304. }
  305. } );
  306. // Deactivate delegating when view is removed from this collection.
  307. this.on( 'remove', ( evt, view ) => {
  308. for ( let evtName of events ) {
  309. view.stopDelegating( evtName, dest );
  310. }
  311. } );
  312. }
  313. };
  314. }
  315. }
  316. mix( Collection, ObservableMixin );
  317. // Check if all entries of the array are of `String` type.
  318. //
  319. // @private
  320. // @param {Array} arr An array to be checked.
  321. // @returns {Boolean}
  322. function isStringArray( arr ) {
  323. return arr.every( a => typeof a == 'string' );
  324. }