Merge Chromium + Blink git repositories
[chromium-blink-merge.git] / ui / file_manager / gallery / js / ribbon.js
blob57857b3ff653303cb522eecbd898864da9b761dd
1 // Copyright 2014 The Chromium Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
5 /**
6  * Scrollable thumbnail ribbon at the bottom of the Gallery in the Slide mode.
7  *
8  * @param {!Document} document Document.
9  * @param {!Window} targetWindow A window which this ribbon is attached to.
10  * @param {!cr.ui.ArrayDataModel} dataModel Data model.
11  * @param {!cr.ui.ListSelectionModel} selectionModel Selection model.
12  * @param {!ThumbnailModel} thumbnailModel
13  * @extends {HTMLDivElement}
14  * @constructor
15  * @struct
16  */
17 function Ribbon(
18     document, targetWindow, dataModel, selectionModel, thumbnailModel) {
19   if (this instanceof Ribbon) {
20     return Ribbon.call(/** @type {Ribbon} */ (document.createElement('div')),
21         document, targetWindow, dataModel, selectionModel, thumbnailModel);
22   }
24   this.__proto__ = Ribbon.prototype;
25   this.className = 'ribbon';
26   this.setAttribute('role', 'listbox');
27   this.tabIndex = 0;
29   /**
30    * @private {!Window}
31    * @const
32    */
33   this.targetWindow_ = targetWindow;
35   /**
36    * @private {!cr.ui.ArrayDataModel}
37    * @const
38    */
39   this.dataModel_ = dataModel;
41   /**
42    * @private {!cr.ui.ListSelectionModel}
43    * @const
44    */
45   this.selectionModel_ = selectionModel;
47   /**
48    * @private {!ThumbnailModel}
49    * @const
50    */
51   this.thumbnailModel_ = thumbnailModel;
53   /**
54    * @type {!Object}
55    * @private
56    */
57   this.renderCache_ = {};
59   /**
60    * @type {number}
61    * @private
62    */
63   this.firstVisibleIndex_ = 0;
65   /**
66    * @type {number}
67    * @private
68    */
69   this.lastVisibleIndex_ = -1;
71   /**
72    * @type {?function(!Event)}
73    * @private
74    */
75   this.onContentBound_ = null;
77   /**
78    * @type {?function(!Event)}
79    * @private
80    */
81   this.onSpliceBound_ = null;
83   /**
84    * @type {?function(!Event)}
85    * @private
86    */
87   this.onSelectionBound_ = null;
89   /**
90    * @type {?number}
91    * @private
92    */
93   this.removeTimeout_ = null;
95   /**
96    * @private {number}
97    */
98   this.thumbnailElementId_ = 0;
100   this.targetWindow_.addEventListener(
101       'resize', this.onWindowResize_.bind(this));
103   return this;
107  * Inherit from HTMLDivElement.
108  */
109 Ribbon.prototype.__proto__ = HTMLDivElement.prototype;
112  * Margin of thumbnails.
113  * @const {number}
114  */
115 Ribbon.MARGIN = 2; // px
118  * Width of thumbnail on the ribbon.
119  * @const {number}
120  */
121 Ribbon.THUMBNAIL_WIDTH = 71; // px
124  * Height of thumbnail on the ribbon.
125  * @const {number}
126  */
127 Ribbon.THUMBNAIL_HEIGHT = 40; // px
130  * Returns number of items in the viewport.
131  * @return {number} Number of items in the viewport.
132  */
133 Ribbon.prototype.getItemCount_ = function() {
134   return Math.ceil(this.targetWindow_.innerWidth /
135       (Ribbon.THUMBNAIL_WIDTH + Ribbon.MARGIN * 2));
139  * Handles resize event of target window.
140  */
141 Ribbon.prototype.onWindowResize_ = function() {
142   this.redraw();
146  * Force redraw the ribbon.
147  */
148 Ribbon.prototype.redraw = function() {
149   this.onSelection_();
153  * Clear all cached data to force full redraw on the next selection change.
154  */
155 Ribbon.prototype.reset = function() {
156   this.renderCache_ = {};
157   this.firstVisibleIndex_ = 0;
158   this.lastVisibleIndex_ = -1;  // Zero thumbnails
162  * Enable the ribbon.
163  */
164 Ribbon.prototype.enable = function() {
165   this.onContentBound_ = this.onContentChange_.bind(this);
166   this.dataModel_.addEventListener('content', this.onContentBound_);
168   this.onSpliceBound_ = this.onSplice_.bind(this);
169   this.dataModel_.addEventListener('splice', this.onSpliceBound_);
171   this.onSelectionBound_ = this.onSelection_.bind(this);
172   this.selectionModel_.addEventListener('change', this.onSelectionBound_);
174   this.reset();
175   this.redraw();
179  * Disable ribbon.
180  */
181 Ribbon.prototype.disable = function() {
182   this.dataModel_.removeEventListener('content', this.onContentBound_);
183   this.dataModel_.removeEventListener('splice', this.onSpliceBound_);
184   this.selectionModel_.removeEventListener('change', this.onSelectionBound_);
186   this.removeVanishing_();
187   this.textContent = '';
191  * Data model splice handler.
192  * @param {!Event} event Event.
193  * @private
194  */
195 Ribbon.prototype.onSplice_ = function(event) {
196   if (event.removed.length === 0 && event.added.length === 0)
197     return;
199   if (event.removed.length > 0 && event.added.length > 0) {
200     console.error('Replacing is not implemented.');
201     return;
202   }
204   if (event.added.length > 0) {
205     for (var i = 0; i < event.added.length; i++) {
206       var index = this.dataModel_.indexOf(event.added[i]);
207       if (index === -1)
208         continue;
209       var element = this.renderThumbnail_(index);
210       var nextItem = this.dataModel_.item(index + 1);
211       var nextElement =
212           nextItem && this.renderCache_[nextItem.getEntry().toURL()];
213       this.insertBefore(element, nextElement);
214     }
215     return;
216   }
218   var persistentNodes = this.querySelectorAll('.ribbon-image:not([vanishing])');
219   if (this.lastVisibleIndex_ < this.dataModel_.length) { // Not at the end.
220     var lastNode = persistentNodes[persistentNodes.length - 1];
221     if (lastNode.nextSibling) {
222       // Pull back a vanishing node from the right.
223       lastNode.nextSibling.removeAttribute('vanishing');
224     } else {
225       // Push a new item at the right end.
226       this.appendChild(this.renderThumbnail_(this.lastVisibleIndex_));
227     }
228   } else {
229     // No items to the right, move the window to the left.
230     this.lastVisibleIndex_--;
231     if (this.firstVisibleIndex_) {
232       this.firstVisibleIndex_--;
233       var firstNode = persistentNodes[0];
234       if (firstNode.previousSibling) {
235         // Pull back a vanishing node from the left.
236         firstNode.previousSibling.removeAttribute('vanishing');
237       } else {
238         // Push a new item at the left end.
239         if (this.firstVisibleIndex_ < this.dataModel_.length) {
240           var newThumbnail = this.renderThumbnail_(this.firstVisibleIndex_);
241           newThumbnail.style.marginLeft = -(this.clientHeight - 2) + 'px';
242           this.insertBefore(newThumbnail, this.firstChild);
243           setTimeout(function() {
244             newThumbnail.style.marginLeft = '0';
245           }, 0);
246         }
247       }
248     }
249   }
251   var removed = false;
252   for (var i = 0; i < event.removed.length; i++) {
253     var removedDom = this.renderCache_[event.removed[i].getEntry().toURL()];
254     if (removedDom) {
255       removedDom.removeAttribute('selected');
256       removedDom.setAttribute('vanishing', 'smooth');
257       removed = true;
258     }
259   }
261   if (removed)
262     this.scheduleRemove_();
264   this.onSelection_();
268  * Selection change handler.
269  * @private
270  */
271 Ribbon.prototype.onSelection_ = function() {
272   var indexes = this.selectionModel_.selectedIndexes;
273   if (indexes.length === 0)
274     return;  // Ignore temporary empty selection.
275   var selectedIndex = indexes[0];
277   var length = this.dataModel_.length;
278   var fullItems = Math.min(this.getItemCount_(), length);
279   var right = Math.floor((fullItems - 1) / 2);
281   var lastIndex = selectedIndex + right;
282   lastIndex = Math.max(lastIndex, fullItems - 1);
283   lastIndex = Math.min(lastIndex, length - 1);
285   var firstIndex = lastIndex - fullItems + 1;
287   if (this.firstVisibleIndex_ !== firstIndex ||
288       this.lastVisibleIndex_ !== lastIndex) {
290     if (this.lastVisibleIndex_ === -1) {
291       this.firstVisibleIndex_ = firstIndex;
292       this.lastVisibleIndex_ = lastIndex;
293     }
295     this.removeVanishing_();
297     this.textContent = '';
298     var startIndex = Math.min(firstIndex, this.firstVisibleIndex_);
299     // All the items except the first one treated equally.
300     for (var index = startIndex + 1;
301          index <= Math.max(lastIndex, this.lastVisibleIndex_);
302          ++index) {
303       // Only add items that are in either old or the new viewport.
304       if (this.lastVisibleIndex_ < index && index < firstIndex ||
305           lastIndex < index && index < this.firstVisibleIndex_)
306         continue;
308       var box = this.renderThumbnail_(index);
309       box.style.marginLeft = Ribbon.MARGIN + 'px';
310       this.appendChild(box);
312       if (index < firstIndex || index > lastIndex) {
313         // If the node is not in the new viewport we only need it while
314         // the animation is playing out.
315         box.setAttribute('vanishing', 'slide');
316       }
317     }
319     var slideCount = this.childNodes.length + 1 - fullItems;
320     var margin = Ribbon.THUMBNAIL_WIDTH * slideCount;
321     var startBox = this.renderThumbnail_(startIndex);
323     if (startIndex === firstIndex) {
324       // Sliding to the right.
325       startBox.style.marginLeft = -margin + 'px';
327       if (this.firstChild)
328         this.insertBefore(startBox, this.firstChild);
329       else
330         this.appendChild(startBox);
332       setTimeout(function() {
333         startBox.style.marginLeft = Ribbon.MARGIN + 'px';
334       }, 0);
335     } else {
336       // Sliding to the left. Start item will become invisible and should be
337       // removed afterwards.
338       startBox.setAttribute('vanishing', 'slide');
339       startBox.style.marginLeft = Ribbon.MARGIN + 'px';
341       if (this.firstChild)
342         this.insertBefore(startBox, this.firstChild);
343       else
344         this.appendChild(startBox);
346       setTimeout(function() {
347         startBox.style.marginLeft = -margin + 'px';
348       }, 0);
349     }
351     this.firstVisibleIndex_ = firstIndex;
352     this.lastVisibleIndex_ = lastIndex;
354     this.scheduleRemove_();
355   }
357   ImageUtil.setClass(
358       this,
359       'fade-left',
360       firstIndex > 0 && selectedIndex !== firstIndex);
361   ImageUtil.setClass(
362       this,
363       'fade-right',
364       lastIndex < length - 1 && selectedIndex !== lastIndex);
366   var oldSelected = this.querySelector('[selected]');
367   if (oldSelected)
368     oldSelected.removeAttribute('selected');
370   var newSelected =
371       this.renderCache_[this.dataModel_.item(selectedIndex).getEntry().toURL()];
372   if (newSelected) {
373     newSelected.setAttribute('selected', true);
374     this.setAttribute('aria-activedescendant', newSelected.id);
375     this.focus();
376   }
380  * Schedule the removal of thumbnails marked as vanishing.
381  * @private
382  */
383 Ribbon.prototype.scheduleRemove_ = function() {
384   if (this.removeTimeout_)
385     clearTimeout(this.removeTimeout_);
387   this.removeTimeout_ = setTimeout(function() {
388     this.removeTimeout_ = null;
389     this.removeVanishing_();
390   }.bind(this), 200);
394  * Remove all thumbnails marked as vanishing.
395  * @private
396  */
397 Ribbon.prototype.removeVanishing_ = function() {
398   if (this.removeTimeout_) {
399     clearTimeout(this.removeTimeout_);
400     this.removeTimeout_ = 0;
401   }
402   var vanishingNodes = this.querySelectorAll('[vanishing]');
403   for (var i = 0; i != vanishingNodes.length; i++) {
404     vanishingNodes[i].removeAttribute('vanishing');
405     this.removeChild(vanishingNodes[i]);
406   }
410  * Create a DOM element for a thumbnail.
412  * @param {number} index Item index.
413  * @return {!Element} Newly created element.
414  * @private
415  */
416 Ribbon.prototype.renderThumbnail_ = function(index) {
417   var item = assertInstanceof(this.dataModel_.item(index), Gallery.Item);
418   var url = item.getEntry().toURL();
420   var cached = this.renderCache_[url];
421   if (cached) {
422     var img = cached.querySelector('img');
423     if (img)
424       img.classList.add('cached');
425     return cached;
426   }
428   var thumbnail = assertInstanceof(this.ownerDocument.createElement('div'),
429       HTMLDivElement);
430   thumbnail.id = `thumbnail-${this.thumbnailElementId_++}`;
431   thumbnail.className = 'ribbon-image';
432   thumbnail.setAttribute('role', 'listitem');
433   thumbnail.addEventListener('click', function() {
434     var index = this.dataModel_.indexOf(item);
435     this.selectionModel_.unselectAll();
436     this.selectionModel_.setIndexSelected(index, true);
437   }.bind(this));
439   util.createChild(thumbnail, 'image-wrapper');
440   util.createChild(thumbnail, 'selection-frame');
442   this.setThumbnailImage_(thumbnail, item);
444   // TODO: Implement LRU eviction.
445   // Never evict the thumbnails that are currently in the DOM because we rely
446   // on this cache to find them by URL.
447   this.renderCache_[url] = thumbnail;
448   return thumbnail;
452  * Set the thumbnail image.
454  * @param {!Element} thumbnail Thumbnail element.
455  * @param {!Gallery.Item} item Gallery item.
456  * @private
457  */
458 Ribbon.prototype.setThumbnailImage_ = function(thumbnail, item) {
459   thumbnail.setAttribute('title', item.getFileName());
461   if (!item.getThumbnailMetadataItem())
462     return;
464   this.thumbnailModel_.get([item.getEntry()]).then(function(metadataList) {
465     var loader = new ThumbnailLoader(
466         item.getEntry(),
467         ThumbnailLoader.LoaderType.IMAGE,
468         metadataList[0]);
469     // Pass 0.35 as auto fill threshold. This value allows to fill 4:3 and 3:2
470     // photos in 16:9 box (ratio factors for them are ~1.34 and ~1.18
471     // respectively).
472     loader.load(
473         thumbnail.querySelector('.image-wrapper'),
474         ThumbnailLoader.FillMode.AUTO,
475         ThumbnailLoader.OptimizationMode.NEVER_DISCARD,
476         undefined /* opt_onSuccess */,
477         undefined /* opt_onError */,
478         undefined /* opt_onGeneric */,
479         0.35 /* opt_autoFillThreshold */,
480         Ribbon.THUMBNAIL_WIDTH /* opt_boxWidth */,
481         Ribbon.THUMBNAIL_HEIGHT /* opt_boxHeight */);
482   });
486  * Content change handler.
488  * @param {!Event} event Event.
489  * @private
490  */
491 Ribbon.prototype.onContentChange_ = function(event) {
492   var url = event.item.getEntry().toURL();
493   if (event.oldEntry.toURL() !== url)
494     this.remapCache_(event.oldEntry.toURL(), url);
496   var thumbnail = this.renderCache_[url];
497   if (thumbnail && event.item)
498     this.setThumbnailImage_(thumbnail, event.item);
502  * Update the thumbnail element cache.
504  * @param {string} oldUrl Old url.
505  * @param {string} newUrl New url.
506  * @private
507  */
508 Ribbon.prototype.remapCache_ = function(oldUrl, newUrl) {
509   if (oldUrl != newUrl && (oldUrl in this.renderCache_)) {
510     this.renderCache_[newUrl] = this.renderCache_[oldUrl];
511     delete this.renderCache_[oldUrl];
512   }