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.
4
5'use strict';
6
7/**
8 * Scrollable thumbnail ribbon at the bottom of the Gallery in the Slide mode.
9 *
10 * @param {Document} document Document.
11 * @param {cr.ui.ArrayDataModel} dataModel Data model.
12 * @param {cr.ui.ListSelectionModel} selectionModel Selection model.
13 * @return {Element} Ribbon element.
14 * @constructor
15 */
16function Ribbon(document, dataModel, selectionModel) {
17  var self = document.createElement('div');
18  Ribbon.decorate(self, dataModel, selectionModel);
19  return self;
20}
21
22/**
23 * Inherit from HTMLDivElement.
24 */
25Ribbon.prototype.__proto__ = HTMLDivElement.prototype;
26
27/**
28 * Decorate a Ribbon instance.
29 *
30 * @param {Ribbon} self Self pointer.
31 * @param {cr.ui.ArrayDataModel} dataModel Data model.
32 * @param {cr.ui.ListSelectionModel} selectionModel Selection model.
33 */
34Ribbon.decorate = function(self, dataModel, selectionModel) {
35  self.__proto__ = Ribbon.prototype;
36  self.dataModel_ = dataModel;
37  self.selectionModel_ = selectionModel;
38
39  self.className = 'ribbon';
40};
41
42/**
43 * Max number of thumbnails in the ribbon.
44 * @type {number}
45 */
46Ribbon.ITEMS_COUNT = 5;
47
48/**
49 * Force redraw the ribbon.
50 */
51Ribbon.prototype.redraw = function() {
52  this.onSelection_();
53};
54
55/**
56 * Clear all cached data to force full redraw on the next selection change.
57 */
58Ribbon.prototype.reset = function() {
59  this.renderCache_ = {};
60  this.firstVisibleIndex_ = 0;
61  this.lastVisibleIndex_ = -1;  // Zero thumbnails
62};
63
64/**
65 * Enable the ribbon.
66 */
67Ribbon.prototype.enable = function() {
68  this.onContentBound_ = this.onContentChange_.bind(this);
69  this.dataModel_.addEventListener('content', this.onContentBound_);
70
71  this.onSpliceBound_ = this.onSplice_.bind(this);
72  this.dataModel_.addEventListener('splice', this.onSpliceBound_);
73
74  this.onSelectionBound_ = this.onSelection_.bind(this);
75  this.selectionModel_.addEventListener('change', this.onSelectionBound_);
76
77  this.reset();
78  this.redraw();
79};
80
81/**
82 * Disable ribbon.
83 */
84Ribbon.prototype.disable = function() {
85  this.dataModel_.removeEventListener('content', this.onContentBound_);
86  this.dataModel_.removeEventListener('splice', this.onSpliceBound_);
87  this.selectionModel_.removeEventListener('change', this.onSelectionBound_);
88
89  this.removeVanishing_();
90  this.textContent = '';
91};
92
93/**
94 * Data model splice handler.
95 * @param {Event} event Event.
96 * @private
97 */
98Ribbon.prototype.onSplice_ = function(event) {
99  if (event.removed.length > 1) {
100    console.error('Cannot remove multiple items.');
101    return;
102  }
103
104  if (event.removed.length > 0 && event.added.length > 0) {
105    console.error('Replacing is not implemented.');
106    return;
107  }
108
109  if (event.added.length > 0) {
110    for (var i = 0; i < event.added.length; i++) {
111      var index = this.dataModel_.indexOf(event.added[i]);
112      if (index === -1)
113        continue;
114      var element = this.renderThumbnail_(index);
115      var nextItem = this.dataModel_.item(index + 1);
116      var nextElement =
117          nextItem && this.renderCache_[nextItem.getEntry().toURL()];
118      this.insertBefore(element, nextElement);
119    }
120    return;
121  }
122
123  var removed = this.renderCache_[event.removed[0].getEntry().toURL()];
124  if (!removed || !removed.parentNode || !removed.hasAttribute('selected')) {
125    console.error('Can only remove the selected item');
126    return;
127  }
128
129  var persistentNodes = this.querySelectorAll('.ribbon-image:not([vanishing])');
130  if (this.lastVisibleIndex_ < this.dataModel_.length) { // Not at the end.
131    var lastNode = persistentNodes[persistentNodes.length - 1];
132    if (lastNode.nextSibling) {
133      // Pull back a vanishing node from the right.
134      lastNode.nextSibling.removeAttribute('vanishing');
135    } else {
136      // Push a new item at the right end.
137      this.appendChild(this.renderThumbnail_(this.lastVisibleIndex_));
138    }
139  } else {
140    // No items to the right, move the window to the left.
141    this.lastVisibleIndex_--;
142    if (this.firstVisibleIndex_) {
143      this.firstVisibleIndex_--;
144      var firstNode = persistentNodes[0];
145      if (firstNode.previousSibling) {
146        // Pull back a vanishing node from the left.
147        firstNode.previousSibling.removeAttribute('vanishing');
148      } else {
149        // Push a new item at the left end.
150        var newThumbnail = this.renderThumbnail_(this.firstVisibleIndex_);
151        newThumbnail.style.marginLeft = -(this.clientHeight - 2) + 'px';
152        this.insertBefore(newThumbnail, this.firstChild);
153        setTimeout(function() {
154          newThumbnail.style.marginLeft = '0';
155        }, 0);
156      }
157    }
158  }
159
160  removed.removeAttribute('selected');
161  removed.setAttribute('vanishing', 'smooth');
162  this.scheduleRemove_();
163};
164
165/**
166 * Selection change handler.
167 * @private
168 */
169Ribbon.prototype.onSelection_ = function() {
170  var indexes = this.selectionModel_.selectedIndexes;
171  if (indexes.length == 0)
172    return;  // Ignore temporary empty selection.
173  var selectedIndex = indexes[0];
174
175  var length = this.dataModel_.length;
176
177  // TODO(dgozman): use margin instead of 2 here.
178  var itemWidth = this.clientHeight - 2;
179  var fullItems = Math.min(Ribbon.ITEMS_COUNT, length);
180  var right = Math.floor((fullItems - 1) / 2);
181
182  var fullWidth = fullItems * itemWidth;
183  this.style.width = fullWidth + 'px';
184
185  var lastIndex = selectedIndex + right;
186  lastIndex = Math.max(lastIndex, fullItems - 1);
187  lastIndex = Math.min(lastIndex, length - 1);
188  var firstIndex = lastIndex - fullItems + 1;
189
190  if (this.firstVisibleIndex_ != firstIndex ||
191      this.lastVisibleIndex_ != lastIndex) {
192
193    if (this.lastVisibleIndex_ == -1) {
194      this.firstVisibleIndex_ = firstIndex;
195      this.lastVisibleIndex_ = lastIndex;
196    }
197
198    this.removeVanishing_();
199
200    this.textContent = '';
201    var startIndex = Math.min(firstIndex, this.firstVisibleIndex_);
202    // All the items except the first one treated equally.
203    for (var index = startIndex + 1;
204         index <= Math.max(lastIndex, this.lastVisibleIndex_);
205         ++index) {
206      // Only add items that are in either old or the new viewport.
207      if (this.lastVisibleIndex_ < index && index < firstIndex ||
208          lastIndex < index && index < this.firstVisibleIndex_)
209        continue;
210      var box = this.renderThumbnail_(index);
211      box.style.marginLeft = '0';
212      this.appendChild(box);
213      if (index < firstIndex || index > lastIndex) {
214        // If the node is not in the new viewport we only need it while
215        // the animation is playing out.
216        box.setAttribute('vanishing', 'slide');
217      }
218    }
219
220    var slideCount = this.childNodes.length + 1 - Ribbon.ITEMS_COUNT;
221    var margin = itemWidth * slideCount;
222    var startBox = this.renderThumbnail_(startIndex);
223    if (startIndex == firstIndex) {
224      // Sliding to the right.
225      startBox.style.marginLeft = -margin + 'px';
226      if (this.firstChild)
227        this.insertBefore(startBox, this.firstChild);
228      else
229        this.appendChild(startBox);
230      setTimeout(function() {
231        startBox.style.marginLeft = '0';
232      }, 0);
233    } else {
234      // Sliding to the left. Start item will become invisible and should be
235      // removed afterwards.
236      startBox.setAttribute('vanishing', 'slide');
237      startBox.style.marginLeft = '0';
238      if (this.firstChild)
239        this.insertBefore(startBox, this.firstChild);
240      else
241        this.appendChild(startBox);
242      setTimeout(function() {
243        startBox.style.marginLeft = -margin + 'px';
244      }, 0);
245    }
246
247    ImageUtil.setClass(this, 'fade-left',
248        firstIndex > 0 && selectedIndex != firstIndex);
249
250    ImageUtil.setClass(this, 'fade-right',
251        lastIndex < length - 1 && selectedIndex != lastIndex);
252
253    this.firstVisibleIndex_ = firstIndex;
254    this.lastVisibleIndex_ = lastIndex;
255
256    this.scheduleRemove_();
257  }
258
259  var oldSelected = this.querySelector('[selected]');
260  if (oldSelected)
261    oldSelected.removeAttribute('selected');
262
263  var newSelected =
264      this.renderCache_[this.dataModel_.item(selectedIndex).getEntry().toURL()];
265  if (newSelected)
266    newSelected.setAttribute('selected', true);
267};
268
269/**
270 * Schedule the removal of thumbnails marked as vanishing.
271 * @private
272 */
273Ribbon.prototype.scheduleRemove_ = function() {
274  if (this.removeTimeout_)
275    clearTimeout(this.removeTimeout_);
276
277  this.removeTimeout_ = setTimeout(function() {
278    this.removeTimeout_ = null;
279    this.removeVanishing_();
280  }.bind(this), 200);
281};
282
283/**
284 * Remove all thumbnails marked as vanishing.
285 * @private
286 */
287Ribbon.prototype.removeVanishing_ = function() {
288  if (this.removeTimeout_) {
289    clearTimeout(this.removeTimeout_);
290    this.removeTimeout_ = 0;
291  }
292  var vanishingNodes = this.querySelectorAll('[vanishing]');
293  for (var i = 0; i != vanishingNodes.length; i++) {
294    vanishingNodes[i].removeAttribute('vanishing');
295    this.removeChild(vanishingNodes[i]);
296  }
297};
298
299/**
300 * Create a DOM element for a thumbnail.
301 *
302 * @param {number} index Item index.
303 * @return {Element} Newly created element.
304 * @private
305 */
306Ribbon.prototype.renderThumbnail_ = function(index) {
307  var item = this.dataModel_.item(index);
308  var url = item.getEntry().toURL();
309
310  var cached = this.renderCache_[url];
311  if (cached) {
312    var img = cached.querySelector('img');
313    if (img)
314      img.classList.add('cached');
315    return cached;
316  }
317
318  var thumbnail = this.ownerDocument.createElement('div');
319  thumbnail.className = 'ribbon-image';
320  thumbnail.addEventListener('click', function() {
321    var index = this.dataModel_.indexOf(item);
322    this.selectionModel_.unselectAll();
323    this.selectionModel_.setIndexSelected(index, true);
324  }.bind(this));
325
326  util.createChild(thumbnail, 'image-wrapper');
327
328  this.setThumbnailImage_(thumbnail, item);
329
330  // TODO: Implement LRU eviction.
331  // Never evict the thumbnails that are currently in the DOM because we rely
332  // on this cache to find them by URL.
333  this.renderCache_[url] = thumbnail;
334  return thumbnail;
335};
336
337/**
338 * Set the thumbnail image.
339 *
340 * @param {Element} thumbnail Thumbnail element.
341 * @param {Gallery.Item} item Gallery item.
342 * @private
343 */
344Ribbon.prototype.setThumbnailImage_ = function(thumbnail, item) {
345  var loader = new ThumbnailLoader(
346      item.getEntry(),
347      ThumbnailLoader.LoaderType.IMAGE,
348      item.getMetadata());
349  loader.load(
350      thumbnail.querySelector('.image-wrapper'),
351      ThumbnailLoader.FillMode.FILL /* fill */,
352      ThumbnailLoader.OptimizationMode.NEVER_DISCARD);
353};
354
355/**
356 * Content change handler.
357 *
358 * @param {Event} event Event.
359 * @private
360 */
361Ribbon.prototype.onContentChange_ = function(event) {
362  var url = event.item.getEntry().toURL();
363  if (event.oldEntry.toURL() !== url)
364    this.remapCache_(event.oldEntry.toURL(), url);
365
366  var thumbnail = this.renderCache_[url];
367  if (thumbnail && event.item)
368    this.setThumbnailImage_(thumbnail, event.item);
369};
370
371/**
372 * Update the thumbnail element cache.
373 *
374 * @param {string} oldUrl Old url.
375 * @param {string} newUrl New url.
376 * @private
377 */
378Ribbon.prototype.remapCache_ = function(oldUrl, newUrl) {
379  if (oldUrl != newUrl && (oldUrl in this.renderCache_)) {
380    this.renderCache_[newUrl] = this.renderCache_[oldUrl];
381    delete this.renderCache_[oldUrl];
382  }
383};
384