blob: c86c037d47e87473bf9a8ffd323054933708c225 [file] [log] [blame]
// Copyright 2014 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* Scrollable thumbnail ribbon at the bottom of the Gallery in the Slide mode.
*
* @param {!Document} document Document.
* @param {!Window} targetWindow A window which this ribbon is attached to.
* @param {!cr.ui.ArrayDataModel} dataModel Data model.
* @param {!cr.ui.ListSelectionModel} selectionModel Selection model.
* @param {!ThumbnailModel} thumbnailModel
* @extends {HTMLDivElement}
* @constructor
* @struct
*/
function Ribbon(
document, targetWindow, dataModel, selectionModel, thumbnailModel) {
if (this instanceof Ribbon) {
return Ribbon.call(/** @type {Ribbon} */ (document.createElement('div')),
document, targetWindow, dataModel, selectionModel, thumbnailModel);
}
this.__proto__ = Ribbon.prototype;
this.className = 'ribbon';
this.setAttribute('role', 'listbox');
this.tabIndex = 0;
/**
* @private {!Window}
* @const
*/
this.targetWindow_ = targetWindow;
/**
* @private {!cr.ui.ArrayDataModel}
* @const
*/
this.dataModel_ = dataModel;
/**
* @private {!cr.ui.ListSelectionModel}
* @const
*/
this.selectionModel_ = selectionModel;
/**
* @private {!ThumbnailModel}
* @const
*/
this.thumbnailModel_ = thumbnailModel;
/**
* @type {!Object}
* @private
*/
this.renderCache_ = {};
/**
* @type {number}
* @private
*/
this.firstVisibleIndex_ = 0;
/**
* @type {number}
* @private
*/
this.lastVisibleIndex_ = -1;
/**
* @type {?function(!Event)}
* @private
*/
this.onContentBound_ = null;
/**
* @type {?function(!Event)}
* @private
*/
this.onSpliceBound_ = null;
/**
* @type {?function(!Event)}
* @private
*/
this.onSelectionBound_ = null;
/**
* @type {?number}
* @private
*/
this.removeTimeout_ = null;
/**
* @private {number}
*/
this.thumbnailElementId_ = 0;
this.targetWindow_.addEventListener(
'resize', this.onWindowResize_.bind(this));
return this;
}
/**
* Inherit from HTMLDivElement.
*/
Ribbon.prototype.__proto__ = HTMLDivElement.prototype;
/**
* Margin of thumbnails.
* @const {number}
*/
Ribbon.MARGIN = 2; // px
/**
* Width of thumbnail on the ribbon.
* @const {number}
*/
Ribbon.THUMBNAIL_WIDTH = 71; // px
/**
* Height of thumbnail on the ribbon.
* @const {number}
*/
Ribbon.THUMBNAIL_HEIGHT = 40; // px
/**
* Returns number of items in the viewport.
* @return {number} Number of items in the viewport.
*/
Ribbon.prototype.getItemCount_ = function() {
return Math.ceil(this.targetWindow_.innerWidth /
(Ribbon.THUMBNAIL_WIDTH + Ribbon.MARGIN * 2));
};
/**
* Handles resize event of target window.
*/
Ribbon.prototype.onWindowResize_ = function() {
this.redraw();
};
/**
* Force redraw the ribbon.
*/
Ribbon.prototype.redraw = function() {
this.onSelection_();
};
/**
* Clear all cached data to force full redraw on the next selection change.
*/
Ribbon.prototype.reset = function() {
this.renderCache_ = {};
this.firstVisibleIndex_ = 0;
this.lastVisibleIndex_ = -1; // Zero thumbnails
};
/**
* Enable the ribbon.
*/
Ribbon.prototype.enable = function() {
this.onContentBound_ = this.onContentChange_.bind(this);
this.dataModel_.addEventListener('content', this.onContentBound_);
this.onSpliceBound_ = this.onSplice_.bind(this);
this.dataModel_.addEventListener('splice', this.onSpliceBound_);
this.onSelectionBound_ = this.onSelection_.bind(this);
this.selectionModel_.addEventListener('change', this.onSelectionBound_);
this.reset();
this.redraw();
};
/**
* Disable ribbon.
*/
Ribbon.prototype.disable = function() {
this.dataModel_.removeEventListener('content', this.onContentBound_);
this.dataModel_.removeEventListener('splice', this.onSpliceBound_);
this.selectionModel_.removeEventListener('change', this.onSelectionBound_);
this.removeVanishing_();
this.textContent = '';
};
/**
* Data model splice handler.
* @param {!Event} event Event.
* @private
*/
Ribbon.prototype.onSplice_ = function(event) {
if (event.removed.length === 0 && event.added.length === 0)
return;
if (event.removed.length > 0 && event.added.length > 0) {
console.error('Replacing is not implemented.');
return;
}
if (event.added.length > 0) {
for (var i = 0; i < event.added.length; i++) {
var index = this.dataModel_.indexOf(event.added[i]);
if (index === -1)
continue;
var element = this.renderThumbnail_(index);
var nextItem = this.dataModel_.item(index + 1);
var nextElement =
nextItem && this.renderCache_[nextItem.getEntry().toURL()];
this.insertBefore(element, nextElement);
}
return;
}
var persistentNodes = this.querySelectorAll('.ribbon-image:not([vanishing])');
if (this.lastVisibleIndex_ < this.dataModel_.length) { // Not at the end.
var lastNode = persistentNodes[persistentNodes.length - 1];
if (lastNode.nextSibling) {
// Pull back a vanishing node from the right.
lastNode.nextSibling.removeAttribute('vanishing');
} else {
// Push a new item at the right end.
this.appendChild(this.renderThumbnail_(this.lastVisibleIndex_));
}
} else {
// No items to the right, move the window to the left.
this.lastVisibleIndex_--;
if (this.firstVisibleIndex_) {
this.firstVisibleIndex_--;
var firstNode = persistentNodes[0];
if (firstNode.previousSibling) {
// Pull back a vanishing node from the left.
firstNode.previousSibling.removeAttribute('vanishing');
} else {
// Push a new item at the left end.
if (this.firstVisibleIndex_ < this.dataModel_.length) {
var newThumbnail = this.renderThumbnail_(this.firstVisibleIndex_);
newThumbnail.style.marginLeft = -(this.clientHeight - 2) + 'px';
this.insertBefore(newThumbnail, this.firstChild);
setTimeout(function() {
newThumbnail.style.marginLeft = '0';
}, 0);
}
}
}
}
var removed = false;
for (var i = 0; i < event.removed.length; i++) {
var removedDom = this.renderCache_[event.removed[i].getEntry().toURL()];
if (removedDom) {
removedDom.removeAttribute('selected');
removedDom.setAttribute('vanishing', 'smooth');
removed = true;
}
}
if (removed)
this.scheduleRemove_();
this.onSelection_();
};
/**
* Selection change handler.
* @private
*/
Ribbon.prototype.onSelection_ = function() {
var indexes = this.selectionModel_.selectedIndexes;
if (indexes.length === 0)
return; // Ignore temporary empty selection.
var selectedIndex = indexes[0];
var length = this.dataModel_.length;
var fullItems = Math.min(this.getItemCount_(), length);
var right = Math.floor((fullItems - 1) / 2);
var lastIndex = selectedIndex + right;
lastIndex = Math.max(lastIndex, fullItems - 1);
lastIndex = Math.min(lastIndex, length - 1);
var firstIndex = lastIndex - fullItems + 1;
if (this.firstVisibleIndex_ !== firstIndex ||
this.lastVisibleIndex_ !== lastIndex) {
if (this.lastVisibleIndex_ === -1) {
this.firstVisibleIndex_ = firstIndex;
this.lastVisibleIndex_ = lastIndex;
}
this.removeVanishing_();
this.textContent = '';
var startIndex = Math.min(firstIndex, this.firstVisibleIndex_);
// All the items except the first one treated equally.
for (var index = startIndex + 1;
index <= Math.max(lastIndex, this.lastVisibleIndex_);
++index) {
// Only add items that are in either old or the new viewport.
if (this.lastVisibleIndex_ < index && index < firstIndex ||
lastIndex < index && index < this.firstVisibleIndex_)
continue;
var box = this.renderThumbnail_(index);
box.style.marginLeft = Ribbon.MARGIN + 'px';
this.appendChild(box);
if (index < firstIndex || index > lastIndex) {
// If the node is not in the new viewport we only need it while
// the animation is playing out.
box.setAttribute('vanishing', 'slide');
}
}
var slideCount = this.childNodes.length + 1 - fullItems;
var margin = Ribbon.THUMBNAIL_WIDTH * slideCount;
var startBox = this.renderThumbnail_(startIndex);
if (startIndex === firstIndex) {
// Sliding to the right.
startBox.style.marginLeft = -margin + 'px';
if (this.firstChild)
this.insertBefore(startBox, this.firstChild);
else
this.appendChild(startBox);
setTimeout(function() {
startBox.style.marginLeft = Ribbon.MARGIN + 'px';
}, 0);
} else {
// Sliding to the left. Start item will become invisible and should be
// removed afterwards.
startBox.setAttribute('vanishing', 'slide');
startBox.style.marginLeft = Ribbon.MARGIN + 'px';
if (this.firstChild)
this.insertBefore(startBox, this.firstChild);
else
this.appendChild(startBox);
setTimeout(function() {
startBox.style.marginLeft = -margin + 'px';
}, 0);
}
this.firstVisibleIndex_ = firstIndex;
this.lastVisibleIndex_ = lastIndex;
this.scheduleRemove_();
}
ImageUtil.setClass(
this,
'fade-left',
firstIndex > 0 && selectedIndex !== firstIndex);
ImageUtil.setClass(
this,
'fade-right',
lastIndex < length - 1 && selectedIndex !== lastIndex);
var oldSelected = this.querySelector('[selected]');
if (oldSelected)
oldSelected.removeAttribute('selected');
var newSelected =
this.renderCache_[this.dataModel_.item(selectedIndex).getEntry().toURL()];
if (newSelected) {
newSelected.setAttribute('selected', true);
this.setAttribute('aria-activedescendant', newSelected.id);
this.focus();
}
};
/**
* Schedule the removal of thumbnails marked as vanishing.
* @private
*/
Ribbon.prototype.scheduleRemove_ = function() {
if (this.removeTimeout_)
clearTimeout(this.removeTimeout_);
this.removeTimeout_ = setTimeout(function() {
this.removeTimeout_ = null;
this.removeVanishing_();
}.bind(this), 200);
};
/**
* Remove all thumbnails marked as vanishing.
* @private
*/
Ribbon.prototype.removeVanishing_ = function() {
if (this.removeTimeout_) {
clearTimeout(this.removeTimeout_);
this.removeTimeout_ = 0;
}
var vanishingNodes = this.querySelectorAll('[vanishing]');
for (var i = 0; i != vanishingNodes.length; i++) {
vanishingNodes[i].removeAttribute('vanishing');
this.removeChild(vanishingNodes[i]);
}
};
/**
* Create a DOM element for a thumbnail.
*
* @param {number} index Item index.
* @return {!Element} Newly created element.
* @private
*/
Ribbon.prototype.renderThumbnail_ = function(index) {
var item = assertInstanceof(this.dataModel_.item(index), GalleryItem);
var url = item.getEntry().toURL();
var cached = this.renderCache_[url];
if (cached) {
var img = cached.querySelector('img');
if (img)
img.classList.add('cached');
return cached;
}
var thumbnail = assertInstanceof(this.ownerDocument.createElement('div'),
HTMLDivElement);
thumbnail.id = `thumbnail-${this.thumbnailElementId_++}`;
thumbnail.className = 'ribbon-image';
thumbnail.setAttribute('role', 'listitem');
thumbnail.addEventListener('click', function() {
var index = this.dataModel_.indexOf(item);
this.selectionModel_.unselectAll();
this.selectionModel_.setIndexSelected(index, true);
}.bind(this));
util.createChild(thumbnail, 'indicator loading');
util.createChild(thumbnail, 'image-wrapper');
util.createChild(thumbnail, 'selection-frame');
this.setThumbnailImage_(thumbnail, item);
// TODO: Implement LRU eviction.
// Never evict the thumbnails that are currently in the DOM because we rely
// on this cache to find them by URL.
this.renderCache_[url] = thumbnail;
return thumbnail;
};
/**
* Set the thumbnail image.
*
* @param {!Element} thumbnail Thumbnail element.
* @param {!GalleryItem} item Gallery item.
* @private
*/
Ribbon.prototype.setThumbnailImage_ = function(thumbnail, item) {
thumbnail.setAttribute('title', item.getFileName());
if (!item.getThumbnailMetadataItem())
return;
var hideIndicator = function() {
thumbnail.querySelector('.indicator').classList.toggle('loading', false);
};
this.thumbnailModel_.get([item.getEntry()]).then(function(metadataList) {
var loader = new ThumbnailLoader(
item.getEntry(),
ThumbnailLoader.LoaderType.IMAGE,
metadataList[0]);
// Pass 0.35 as auto fill threshold. This value allows to fill 4:3 and 3:2
// photos in 16:9 box (ratio factors for them are ~1.34 and ~1.18
// respectively).
loader.load(
thumbnail.querySelector('.image-wrapper'),
ThumbnailLoader.FillMode.AUTO,
ThumbnailLoader.OptimizationMode.NEVER_DISCARD,
hideIndicator /* opt_onSuccess */,
undefined /* opt_onError */,
undefined /* opt_onGeneric */,
0.35 /* opt_autoFillThreshold */,
Ribbon.THUMBNAIL_WIDTH /* opt_boxWidth */,
Ribbon.THUMBNAIL_HEIGHT /* opt_boxHeight */);
});
};
/**
* Content change handler.
*
* @param {!Event} event Event.
* @private
*/
Ribbon.prototype.onContentChange_ = function(event) {
var url = event.item.getEntry().toURL();
if (event.oldEntry.toURL() !== url)
this.remapCache_(event.oldEntry.toURL(), url);
var thumbnail = this.renderCache_[url];
if (thumbnail && event.item)
this.setThumbnailImage_(thumbnail, event.item);
};
/**
* Update the thumbnail element cache.
*
* @param {string} oldUrl Old url.
* @param {string} newUrl New url.
* @private
*/
Ribbon.prototype.remapCache_ = function(oldUrl, newUrl) {
if (oldUrl != newUrl && (oldUrl in this.renderCache_)) {
this.renderCache_[newUrl] = this.renderCache_[oldUrl];
delete this.renderCache_[oldUrl];
}
};