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