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.
6 * Scrollable thumbnail ribbon at the bottom of the Gallery in the Slide mode.
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}
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);
24 this.__proto__ = Ribbon.prototype;
25 this.className = 'ribbon';
26 this.setAttribute('role', 'listbox');
33 this.targetWindow_ = targetWindow;
36 * @private {!cr.ui.ArrayDataModel}
39 this.dataModel_ = dataModel;
42 * @private {!cr.ui.ListSelectionModel}
45 this.selectionModel_ = selectionModel;
48 * @private {!ThumbnailModel}
51 this.thumbnailModel_ = thumbnailModel;
57 this.renderCache_ = {};
63 this.firstVisibleIndex_ = 0;
69 this.lastVisibleIndex_ = -1;
72 * @type {?function(!Event)}
75 this.onContentBound_ = null;
78 * @type {?function(!Event)}
81 this.onSpliceBound_ = null;
84 * @type {?function(!Event)}
87 this.onSelectionBound_ = null;
93 this.removeTimeout_ = null;
98 this.thumbnailElementId_ = 0;
100 this.targetWindow_.addEventListener(
101 'resize', this.onWindowResize_.bind(this));
107 * Inherit from HTMLDivElement.
109 Ribbon.prototype.__proto__ = HTMLDivElement.prototype;
112 * Margin of thumbnails.
115 Ribbon.MARGIN = 2; // px
118 * Width of thumbnail on the ribbon.
121 Ribbon.THUMBNAIL_WIDTH = 71; // px
124 * Height of thumbnail on the ribbon.
127 Ribbon.THUMBNAIL_HEIGHT = 40; // px
130 * Returns number of items in the viewport.
131 * @return {number} Number of items in the viewport.
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.
141 Ribbon.prototype.onWindowResize_ = function() {
146 * Force redraw the ribbon.
148 Ribbon.prototype.redraw = function() {
153 * Clear all cached data to force full redraw on the next selection change.
155 Ribbon.prototype.reset = function() {
156 this.renderCache_ = {};
157 this.firstVisibleIndex_ = 0;
158 this.lastVisibleIndex_ = -1; // Zero thumbnails
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_);
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.
195 Ribbon.prototype.onSplice_ = function(event) {
196 if (event.removed.length === 0 && event.added.length === 0)
199 if (event.removed.length > 0 && event.added.length > 0) {
200 console.error('Replacing is not implemented.');
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]);
209 var element = this.renderThumbnail_(index);
210 var nextItem = this.dataModel_.item(index + 1);
212 nextItem && this.renderCache_[nextItem.getEntry().toURL()];
213 this.insertBefore(element, nextElement);
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');
225 // Push a new item at the right end.
226 this.appendChild(this.renderThumbnail_(this.lastVisibleIndex_));
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');
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';
252 for (var i = 0; i < event.removed.length; i++) {
253 var removedDom = this.renderCache_[event.removed[i].getEntry().toURL()];
255 removedDom.removeAttribute('selected');
256 removedDom.setAttribute('vanishing', 'smooth');
262 this.scheduleRemove_();
268 * Selection change handler.
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;
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_);
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_)
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');
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';
328 this.insertBefore(startBox, this.firstChild);
330 this.appendChild(startBox);
332 setTimeout(function() {
333 startBox.style.marginLeft = Ribbon.MARGIN + 'px';
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';
342 this.insertBefore(startBox, this.firstChild);
344 this.appendChild(startBox);
346 setTimeout(function() {
347 startBox.style.marginLeft = -margin + 'px';
351 this.firstVisibleIndex_ = firstIndex;
352 this.lastVisibleIndex_ = lastIndex;
354 this.scheduleRemove_();
360 firstIndex > 0 && selectedIndex !== firstIndex);
364 lastIndex < length - 1 && selectedIndex !== lastIndex);
366 var oldSelected = this.querySelector('[selected]');
368 oldSelected.removeAttribute('selected');
371 this.renderCache_[this.dataModel_.item(selectedIndex).getEntry().toURL()];
373 newSelected.setAttribute('selected', true);
374 this.setAttribute('aria-activedescendant', newSelected.id);
380 * Schedule the removal of thumbnails marked as vanishing.
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_();
394 * Remove all thumbnails marked as vanishing.
397 Ribbon.prototype.removeVanishing_ = function() {
398 if (this.removeTimeout_) {
399 clearTimeout(this.removeTimeout_);
400 this.removeTimeout_ = 0;
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]);
410 * Create a DOM element for a thumbnail.
412 * @param {number} index Item index.
413 * @return {!Element} Newly created element.
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];
422 var img = cached.querySelector('img');
424 img.classList.add('cached');
428 var thumbnail = assertInstanceof(this.ownerDocument.createElement('div'),
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);
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;
452 * Set the thumbnail image.
454 * @param {!Element} thumbnail Thumbnail element.
455 * @param {!Gallery.Item} item Gallery item.
458 Ribbon.prototype.setThumbnailImage_ = function(thumbnail, item) {
459 thumbnail.setAttribute('title', item.getFileName());
461 if (!item.getThumbnailMetadataItem())
464 this.thumbnailModel_.get([item.getEntry()]).then(function(metadataList) {
465 var loader = new ThumbnailLoader(
467 ThumbnailLoader.LoaderType.IMAGE,
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
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 */);
486 * Content change handler.
488 * @param {!Event} event Event.
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.
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];