Home | History | Annotate | Download | only in js
      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  * @param {Element} container Content container.
      9  * @param {cr.ui.ArrayDataModel} dataModel Data model.
     10  * @param {cr.ui.ListSelectionModel} selectionModel Selection model.
     11  * @param {MetadataCache} metadataCache Metadata cache.
     12  * @param {VolumeManagerWrapper} volumeManager Volume manager.
     13  * @param {function} toggleMode Function to switch to the Slide mode.
     14  * @constructor
     15  */
     16 function MosaicMode(
     17     container, dataModel, selectionModel, metadataCache, volumeManager,
     18     toggleMode) {
     19   this.mosaic_ = new Mosaic(
     20       container.ownerDocument, dataModel, selectionModel, metadataCache,
     21       volumeManager);
     22   container.appendChild(this.mosaic_);
     23 
     24   this.toggleMode_ = toggleMode;
     25   this.mosaic_.addEventListener('dblclick', this.toggleMode_);
     26   this.showingTimeoutID_ = null;
     27 }
     28 
     29 /**
     30  * @return {Mosaic} The mosaic control.
     31  */
     32 MosaicMode.prototype.getMosaic = function() { return this.mosaic_ };
     33 
     34 /**
     35  * @return {string} Mode name.
     36  */
     37 MosaicMode.prototype.getName = function() { return 'mosaic' };
     38 
     39 /**
     40  * @return {string} Mode title.
     41  */
     42 MosaicMode.prototype.getTitle = function() { return 'GALLERY_MOSAIC' };
     43 
     44 /**
     45  * Execute an action (this mode has no busy state).
     46  * @param {function} action Action to execute.
     47  */
     48 MosaicMode.prototype.executeWhenReady = function(action) { action() };
     49 
     50 /**
     51  * @return {boolean} Always true (no toolbar fading in this mode).
     52  */
     53 MosaicMode.prototype.hasActiveTool = function() { return true };
     54 
     55 /**
     56  * Keydown handler.
     57  *
     58  * @param {Event} event Event.
     59  */
     60 MosaicMode.prototype.onKeyDown = function(event) {
     61   switch (util.getKeyModifiers(event) + event.keyIdentifier) {
     62     case 'Enter':
     63       if (!document.activeElement ||
     64           document.activeElement.localName !== 'button') {
     65         this.toggleMode_();
     66         event.preventDefault();
     67       }
     68       return;
     69   }
     70   this.mosaic_.onKeyDown(event);
     71 };
     72 
     73 ////////////////////////////////////////////////////////////////////////////////
     74 
     75 /**
     76  * Mosaic control.
     77  *
     78  * @param {Document} document Document.
     79  * @param {cr.ui.ArrayDataModel} dataModel Data model.
     80  * @param {cr.ui.ListSelectionModel} selectionModel Selection model.
     81  * @param {MetadataCache} metadataCache Metadata cache.
     82  * @param {VolumeManagerWrapper} volumeManager Volume manager.
     83  * @return {Element} Mosaic element.
     84  * @constructor
     85  */
     86 function Mosaic(document, dataModel, selectionModel, metadataCache,
     87     volumeManager) {
     88   var self = document.createElement('div');
     89   Mosaic.decorate(
     90       self, dataModel, selectionModel, metadataCache, volumeManager);
     91   return self;
     92 }
     93 
     94 /**
     95  * Inherits from HTMLDivElement.
     96  */
     97 Mosaic.prototype.__proto__ = HTMLDivElement.prototype;
     98 
     99 /**
    100  * Default layout delay in ms.
    101  * @const
    102  * @type {number}
    103  */
    104 Mosaic.LAYOUT_DELAY = 200;
    105 
    106 /**
    107  * Smooth scroll animation duration when scrolling using keyboard or
    108  * clicking on a partly visible tile. In ms.
    109  * @const
    110  * @type {number}
    111  */
    112 Mosaic.ANIMATED_SCROLL_DURATION = 500;
    113 
    114 /**
    115  * Decorates a Mosaic instance.
    116  *
    117  * @param {Mosaic} self Self pointer.
    118  * @param {cr.ui.ArrayDataModel} dataModel Data model.
    119  * @param {cr.ui.ListSelectionModel} selectionModel Selection model.
    120  * @param {MetadataCache} metadataCache Metadata cache.
    121  * @param {VolumeManagerWrapper} volumeManager Volume manager.
    122  */
    123 Mosaic.decorate = function(
    124     self, dataModel, selectionModel, metadataCache, volumeManager) {
    125   self.__proto__ = Mosaic.prototype;
    126   self.className = 'mosaic';
    127 
    128   self.dataModel_ = dataModel;
    129   self.selectionModel_ = selectionModel;
    130   self.metadataCache_ = metadataCache;
    131   self.volumeManager_ = volumeManager;
    132 
    133   // Initialization is completed lazily on the first call to |init|.
    134 };
    135 
    136 /**
    137  * Initializes the mosaic element.
    138  */
    139 Mosaic.prototype.init = function() {
    140   if (this.tiles_)
    141     return; // Already initialized, nothing to do.
    142 
    143   this.layoutModel_ = new Mosaic.Layout();
    144   this.onResize_();
    145 
    146   this.selectionController_ =
    147       new Mosaic.SelectionController(this.selectionModel_, this.layoutModel_);
    148 
    149   this.tiles_ = [];
    150   for (var i = 0; i !== this.dataModel_.length; i++) {
    151     var locationInfo =
    152         this.volumeManager_.getLocationInfo(this.dataModel_.item(i).getEntry());
    153     this.tiles_.push(
    154         new Mosaic.Tile(this, this.dataModel_.item(i), locationInfo));
    155   }
    156 
    157   this.selectionModel_.selectedIndexes.forEach(function(index) {
    158     this.tiles_[index].select(true);
    159   }.bind(this));
    160 
    161   this.initTiles_(this.tiles_);
    162 
    163   // The listeners might be called while some tiles are still loading.
    164   this.initListeners_();
    165 };
    166 
    167 /**
    168  * @return {boolean} Whether mosaic is initialized.
    169  */
    170 Mosaic.prototype.isInitialized = function() {
    171   return !!this.tiles_;
    172 };
    173 
    174 /**
    175  * Starts listening to events.
    176  *
    177  * We keep listening to events even when the mosaic is hidden in order to
    178  * keep the layout up to date.
    179  *
    180  * @private
    181  */
    182 Mosaic.prototype.initListeners_ = function() {
    183   this.ownerDocument.defaultView.addEventListener(
    184       'resize', this.onResize_.bind(this));
    185 
    186   var mouseEventBound = this.onMouseEvent_.bind(this);
    187   this.addEventListener('mousemove', mouseEventBound);
    188   this.addEventListener('mousedown', mouseEventBound);
    189   this.addEventListener('mouseup', mouseEventBound);
    190   this.addEventListener('scroll', this.onScroll_.bind(this));
    191 
    192   this.selectionModel_.addEventListener('change', this.onSelection_.bind(this));
    193   this.selectionModel_.addEventListener('leadIndexChange',
    194       this.onLeadChange_.bind(this));
    195 
    196   this.dataModel_.addEventListener('splice', this.onSplice_.bind(this));
    197   this.dataModel_.addEventListener('content', this.onContentChange_.bind(this));
    198 };
    199 
    200 /**
    201  * Smoothly scrolls the container to the specified position using
    202  * f(x) = sqrt(x) speed function normalized to animation duration.
    203  * @param {number} targetPosition Horizontal scroll position in pixels.
    204  */
    205 Mosaic.prototype.animatedScrollTo = function(targetPosition) {
    206   if (this.scrollAnimation_) {
    207     webkitCancelAnimationFrame(this.scrollAnimation_);
    208     this.scrollAnimation_ = null;
    209   }
    210 
    211   // Mouse move events are fired without touching the mouse because of scrolling
    212   // the container. Therefore, these events have to be suppressed.
    213   this.suppressHovering_ = true;
    214 
    215   // Calculates integral area from t1 to t2 of f(x) = sqrt(x) dx.
    216   var integral = function(t1, t2) {
    217     return 2.0 / 3.0 * Math.pow(t2, 3.0 / 2.0) -
    218            2.0 / 3.0 * Math.pow(t1, 3.0 / 2.0);
    219   };
    220 
    221   var delta = targetPosition - this.scrollLeft;
    222   var factor = delta / integral(0, Mosaic.ANIMATED_SCROLL_DURATION);
    223   var startTime = Date.now();
    224   var lastPosition = 0;
    225   var scrollOffset = this.scrollLeft;
    226 
    227   var animationFrame = function() {
    228     var position = Date.now() - startTime;
    229     var step = factor *
    230         integral(Math.max(0, Mosaic.ANIMATED_SCROLL_DURATION - position),
    231                  Math.max(0, Mosaic.ANIMATED_SCROLL_DURATION - lastPosition));
    232     scrollOffset += step;
    233 
    234     var oldScrollLeft = this.scrollLeft;
    235     var newScrollLeft = Math.round(scrollOffset);
    236 
    237     if (oldScrollLeft !== newScrollLeft)
    238       this.scrollLeft = newScrollLeft;
    239 
    240     if (step === 0 || this.scrollLeft !== newScrollLeft) {
    241       this.scrollAnimation_ = null;
    242       // Release the hovering lock after a safe delay to avoid hovering
    243       // a tile because of altering |this.scrollLeft|.
    244       setTimeout(function() {
    245         if (!this.scrollAnimation_)
    246           this.suppressHovering_ = false;
    247       }.bind(this), 100);
    248     } else {
    249       // Continue the animation.
    250       this.scrollAnimation_ = requestAnimationFrame(animationFrame);
    251     }
    252 
    253     lastPosition = position;
    254   }.bind(this);
    255 
    256   // Start the animation.
    257   this.scrollAnimation_ = requestAnimationFrame(animationFrame);
    258 };
    259 
    260 /**
    261  * @return {Mosaic.Tile} Selected tile or undefined if no selection.
    262  */
    263 Mosaic.prototype.getSelectedTile = function() {
    264   return this.tiles_ && this.tiles_[this.selectionModel_.selectedIndex];
    265 };
    266 
    267 /**
    268  * @param {number} index Tile index.
    269  * @return {Rect} Tile's image rectangle.
    270  */
    271 Mosaic.prototype.getTileRect = function(index) {
    272   var tile = this.tiles_[index];
    273   return tile && tile.getImageRect();
    274 };
    275 
    276 /**
    277  * @param {number} index Tile index.
    278  * Scroll the given tile into the viewport.
    279  */
    280 Mosaic.prototype.scrollIntoView = function(index) {
    281   var tile = this.tiles_[index];
    282   if (tile) tile.scrollIntoView();
    283 };
    284 
    285 /**
    286  * Initializes multiple tiles.
    287  *
    288  * @param {Array.<Mosaic.Tile>} tiles Array of tiles.
    289  * @param {function()=} opt_callback Completion callback.
    290  * @private
    291  */
    292 Mosaic.prototype.initTiles_ = function(tiles, opt_callback) {
    293   // We do not want to use tile indices in asynchronous operations because they
    294   // do not survive data model splices. Copy tile references instead.
    295   tiles = tiles.slice();
    296 
    297   // Throttle the metadata access so that we do not overwhelm the file system.
    298   var MAX_CHUNK_SIZE = 10;
    299 
    300   var loadChunk = function() {
    301     if (!tiles.length) {
    302       if (opt_callback) opt_callback();
    303       return;
    304     }
    305     var chunkSize = Math.min(tiles.length, MAX_CHUNK_SIZE);
    306     var loaded = 0;
    307     for (var i = 0; i !== chunkSize; i++) {
    308       this.initTile_(tiles.shift(), function() {
    309         if (++loaded === chunkSize) {
    310           this.layout();
    311           loadChunk();
    312         }
    313       }.bind(this));
    314     }
    315   }.bind(this);
    316 
    317   loadChunk();
    318 };
    319 
    320 /**
    321  * Initializes a single tile.
    322  *
    323  * @param {Mosaic.Tile} tile Tile.
    324  * @param {function()} callback Completion callback.
    325  * @private
    326  */
    327 Mosaic.prototype.initTile_ = function(tile, callback) {
    328   var onImageMeasured = callback;
    329   this.metadataCache_.getOne(tile.getItem().getEntry(), Gallery.METADATA_TYPE,
    330       function(metadata) {
    331         tile.init(metadata, onImageMeasured);
    332       });
    333 };
    334 
    335 /**
    336  * Reloads all tiles.
    337  */
    338 Mosaic.prototype.reload = function() {
    339   this.layoutModel_.reset_();
    340   this.tiles_.forEach(function(t) { t.markUnloaded() });
    341   this.initTiles_(this.tiles_);
    342 };
    343 
    344 /**
    345  * Layouts the tiles in the order of their indices.
    346  *
    347  * Starts where it last stopped (at #0 the first time).
    348  * Stops when all tiles are processed or when the next tile is still loading.
    349  */
    350 Mosaic.prototype.layout = function() {
    351   if (this.layoutTimer_) {
    352     clearTimeout(this.layoutTimer_);
    353     this.layoutTimer_ = null;
    354   }
    355   while (true) {
    356     var index = this.layoutModel_.getTileCount();
    357     if (index === this.tiles_.length)
    358       break; // All tiles done.
    359     var tile = this.tiles_[index];
    360     if (!tile.isInitialized())
    361       break;  // Next layout will try to restart from here.
    362     this.layoutModel_.add(tile, index + 1 === this.tiles_.length);
    363   }
    364   this.loadVisibleTiles_();
    365 };
    366 
    367 /**
    368  * Schedules the layout.
    369  *
    370  * @param {number=} opt_delay Delay in ms.
    371  */
    372 Mosaic.prototype.scheduleLayout = function(opt_delay) {
    373   if (!this.layoutTimer_) {
    374     this.layoutTimer_ = setTimeout(function() {
    375       this.layoutTimer_ = null;
    376       this.layout();
    377     }.bind(this), opt_delay || 0);
    378   }
    379 };
    380 
    381 /**
    382  * Resize handler.
    383  *
    384  * @private
    385  */
    386 Mosaic.prototype.onResize_ = function() {
    387   this.layoutModel_.setViewportSize(this.clientWidth, this.clientHeight -
    388       (Mosaic.Layout.PADDING_TOP + Mosaic.Layout.PADDING_BOTTOM));
    389   this.scheduleLayout();
    390 };
    391 
    392 /**
    393  * Mouse event handler.
    394  *
    395  * @param {Event} event Event.
    396  * @private
    397  */
    398 Mosaic.prototype.onMouseEvent_ = function(event) {
    399   // Navigating with mouse, enable hover state.
    400   if (!this.suppressHovering_)
    401     this.classList.add('hover-visible');
    402 
    403   if (event.type === 'mousemove')
    404     return;
    405 
    406   var index = -1;
    407   for (var target = event.target;
    408        target && (target !== this);
    409        target = target.parentNode) {
    410     if (target.classList.contains('mosaic-tile')) {
    411       index = this.dataModel_.indexOf(target.getItem());
    412       break;
    413     }
    414   }
    415   this.selectionController_.handlePointerDownUp(event, index);
    416 };
    417 
    418 /**
    419  * Scroll handler.
    420  * @private
    421  */
    422 Mosaic.prototype.onScroll_ = function() {
    423   requestAnimationFrame(function() {
    424     this.loadVisibleTiles_();
    425   }.bind(this));
    426 };
    427 
    428 /**
    429  * Selection change handler.
    430  *
    431  * @param {Event} event Event.
    432  * @private
    433  */
    434 Mosaic.prototype.onSelection_ = function(event) {
    435   for (var i = 0; i !== event.changes.length; i++) {
    436     var change = event.changes[i];
    437     var tile = this.tiles_[change.index];
    438     if (tile) tile.select(change.selected);
    439   }
    440 };
    441 
    442 /**
    443  * Leads item change handler.
    444  *
    445  * @param {Event} event Event.
    446  * @private
    447  */
    448 Mosaic.prototype.onLeadChange_ = function(event) {
    449   var index = event.newValue;
    450   if (index >= 0) {
    451     var tile = this.tiles_[index];
    452     if (tile) tile.scrollIntoView();
    453   }
    454 };
    455 
    456 /**
    457  * Splice event handler.
    458  *
    459  * @param {Event} event Event.
    460  * @private
    461  */
    462 Mosaic.prototype.onSplice_ = function(event) {
    463   var index = event.index;
    464   this.layoutModel_.invalidateFromTile_(index);
    465 
    466   if (event.removed.length) {
    467     for (var t = 0; t !== event.removed.length; t++) {
    468       // If the layout for the tile has not done yet, the parent is null.
    469       // And the layout will not be done after onSplice_ because it is removed
    470       // from this.tiles_.
    471       if (this.tiles_[index + t].parentNode)
    472         this.removeChild(this.tiles_[index + t]);
    473     }
    474 
    475     this.tiles_.splice(index, event.removed.length);
    476     this.scheduleLayout(Mosaic.LAYOUT_DELAY);
    477   }
    478 
    479   if (event.added.length) {
    480     var newTiles = [];
    481     for (var t = 0; t !== event.added.length; t++)
    482       newTiles.push(new Mosaic.Tile(this, this.dataModel_.item(index + t)));
    483 
    484     this.tiles_.splice.apply(this.tiles_, [index, 0].concat(newTiles));
    485     this.initTiles_(newTiles);
    486   }
    487 
    488   if (this.tiles_.length !== this.dataModel_.length)
    489     console.error('Mosaic is out of sync');
    490 };
    491 
    492 /**
    493  * Content change handler.
    494  *
    495  * @param {Event} event Event.
    496  * @private
    497  */
    498 Mosaic.prototype.onContentChange_ = function(event) {
    499   if (!this.tiles_)
    500     return;
    501 
    502   if (!event.metadata)
    503     return; // Thumbnail unchanged, nothing to do.
    504 
    505   var index = this.dataModel_.indexOf(event.item);
    506   if (index !== this.selectionModel_.selectedIndex)
    507     console.error('Content changed for unselected item');
    508 
    509   this.layoutModel_.invalidateFromTile_(index);
    510   this.tiles_[index].init(event.metadata, function() {
    511         this.tiles_[index].unload();
    512         this.tiles_[index].load(
    513             Mosaic.Tile.LoadMode.HIGH_DPI,
    514             this.scheduleLayout.bind(this, Mosaic.LAYOUT_DELAY));
    515       }.bind(this));
    516 };
    517 
    518 /**
    519  * Keydown event handler.
    520  *
    521  * @param {Event} event Event.
    522  * @return {boolean} True if the event has been consumed.
    523  */
    524 Mosaic.prototype.onKeyDown = function(event) {
    525   this.selectionController_.handleKeyDown(event);
    526   if (event.defaultPrevented)  // Navigating with keyboard, hide hover state.
    527     this.classList.remove('hover-visible');
    528   return event.defaultPrevented;
    529 };
    530 
    531 /**
    532  * @return {boolean} True if the mosaic zoom effect can be applied. It is
    533  * too slow if there are to many images.
    534  * TODO(kaznacheev): Consider unloading the images that are out of the viewport.
    535  */
    536 Mosaic.prototype.canZoom = function() {
    537   return this.tiles_.length < 100;
    538 };
    539 
    540 /**
    541  * Shows the mosaic.
    542  */
    543 Mosaic.prototype.show = function() {
    544   var duration = ImageView.MODE_TRANSITION_DURATION;
    545   if (this.canZoom()) {
    546     // Fade in in parallel with the zoom effect.
    547     this.setAttribute('visible', 'zooming');
    548   } else {
    549     // Mosaic is not animating but the large image is. Fade in the mosaic
    550     // shortly before the large image animation is done.
    551     duration -= 100;
    552   }
    553   this.showingTimeoutID_ = setTimeout(function() {
    554     this.showingTimeoutID_ = null;
    555     // Make the selection visible.
    556     // If the mosaic is not animated it will start fading in now.
    557     this.setAttribute('visible', 'normal');
    558     this.loadVisibleTiles_();
    559   }.bind(this), duration);
    560 };
    561 
    562 /**
    563  * Hides the mosaic.
    564  */
    565 Mosaic.prototype.hide = function() {
    566   if (this.showingTimeoutID_ !== null) {
    567     clearTimeout(this.showingTimeoutID_);
    568     this.showingTimeoutID_ = null;
    569   }
    570   this.removeAttribute('visible');
    571 };
    572 
    573 /**
    574  * Checks if the mosaic view is visible.
    575  * @return {boolean} True if visible, false otherwise.
    576  * @private
    577  */
    578 Mosaic.prototype.isVisible_ = function() {
    579   return this.hasAttribute('visible');
    580 };
    581 
    582 /**
    583  * Loads visible tiles. Ignores consecutive calls. Does not reload already
    584  * loaded images.
    585  * @private
    586  */
    587 Mosaic.prototype.loadVisibleTiles_ = function() {
    588   if (this.loadVisibleTilesSuppressed_) {
    589     this.loadVisibleTilesScheduled_ = true;
    590     return;
    591   }
    592 
    593   this.loadVisibleTilesSuppressed_ = true;
    594   this.loadVisibleTilesScheduled_ = false;
    595   setTimeout(function() {
    596     this.loadVisibleTilesSuppressed_ = false;
    597     if (this.loadVisibleTilesScheduled_)
    598       this.loadVisibleTiles_();
    599   }.bind(this), 100);
    600 
    601   // Tiles only in the viewport (visible).
    602   var visibleRect = new Rect(0,
    603                              0,
    604                              this.clientWidth,
    605                              this.clientHeight);
    606 
    607   // Tiles in the viewport and also some distance on the left and right.
    608   var renderableRect = new Rect(-this.clientWidth,
    609                                 0,
    610                                 3 * this.clientWidth,
    611                                 this.clientHeight);
    612 
    613   // Unload tiles out of scope.
    614   for (var index = 0; index < this.tiles_.length; index++) {
    615     var tile = this.tiles_[index];
    616     var imageRect = tile.getImageRect();
    617     // Unload a thumbnail.
    618     if (imageRect && !imageRect.intersects(renderableRect))
    619       tile.unload();
    620   }
    621 
    622   // Load the visible tiles first.
    623   var allVisibleLoaded = true;
    624   // Show high-dpi only when the mosaic view is visible.
    625   var loadMode = this.isVisible_() ? Mosaic.Tile.LoadMode.HIGH_DPI :
    626       Mosaic.Tile.LoadMode.LOW_DPI;
    627   for (var index = 0; index < this.tiles_.length; index++) {
    628     var tile = this.tiles_[index];
    629     var imageRect = tile.getImageRect();
    630     // Load a thumbnail.
    631     if (!tile.isLoading(loadMode) && !tile.isLoaded(loadMode) && imageRect &&
    632         imageRect.intersects(visibleRect)) {
    633       tile.load(loadMode, function() {});
    634       allVisibleLoaded = false;
    635     }
    636   }
    637 
    638   // Load also another, nearby, if the visible has been already loaded.
    639   if (allVisibleLoaded) {
    640     for (var index = 0; index < this.tiles_.length; index++) {
    641       var tile = this.tiles_[index];
    642       var imageRect = tile.getImageRect();
    643       // Load a thumbnail.
    644       if (!tile.isLoading() && !tile.isLoaded() && imageRect &&
    645           imageRect.intersects(renderableRect)) {
    646         tile.load(Mosaic.Tile.LoadMode.LOW_DPI, function() {});
    647       }
    648     }
    649   }
    650 };
    651 
    652 /**
    653  * Applies reset the zoom transform.
    654  *
    655  * @param {Rect} tileRect Tile rectangle. Reset the transform if null.
    656  * @param {Rect} imageRect Large image rectangle. Reset the transform if null.
    657  * @param {boolean=} opt_instant True of the transition should be instant.
    658  */
    659 Mosaic.prototype.transform = function(tileRect, imageRect, opt_instant) {
    660   if (opt_instant) {
    661     this.style.webkitTransitionDuration = '0';
    662   } else {
    663     this.style.webkitTransitionDuration =
    664         ImageView.MODE_TRANSITION_DURATION + 'ms';
    665   }
    666 
    667   if (this.canZoom() && tileRect && imageRect) {
    668     var scaleX = imageRect.width / tileRect.width;
    669     var scaleY = imageRect.height / tileRect.height;
    670     var shiftX = (imageRect.left + imageRect.width / 2) -
    671         (tileRect.left + tileRect.width / 2);
    672     var shiftY = (imageRect.top + imageRect.height / 2) -
    673         (tileRect.top + tileRect.height / 2);
    674     this.style.webkitTransform =
    675         'translate(' + shiftX * scaleX + 'px, ' + shiftY * scaleY + 'px)' +
    676         'scaleX(' + scaleX + ') scaleY(' + scaleY + ')';
    677   } else {
    678     this.style.webkitTransform = '';
    679   }
    680 };
    681 
    682 ////////////////////////////////////////////////////////////////////////////////
    683 
    684 /**
    685  * Creates a selection controller that is to be used with grid.
    686  * @param {cr.ui.ListSelectionModel} selectionModel The selection model to
    687  *     interact with.
    688  * @param {Mosaic.Layout} layoutModel The layout model to use.
    689  * @constructor
    690  * @extends {!cr.ui.ListSelectionController}
    691  */
    692 Mosaic.SelectionController = function(selectionModel, layoutModel) {
    693   cr.ui.ListSelectionController.call(this, selectionModel);
    694   this.layoutModel_ = layoutModel;
    695 };
    696 
    697 /**
    698  * Extends cr.ui.ListSelectionController.
    699  */
    700 Mosaic.SelectionController.prototype.__proto__ =
    701     cr.ui.ListSelectionController.prototype;
    702 
    703 /** @override */
    704 Mosaic.SelectionController.prototype.getLastIndex = function() {
    705   return this.layoutModel_.getLaidOutTileCount() - 1;
    706 };
    707 
    708 /** @override */
    709 Mosaic.SelectionController.prototype.getIndexBefore = function(index) {
    710   return this.layoutModel_.getHorizontalAdjacentIndex(index, -1);
    711 };
    712 
    713 /** @override */
    714 Mosaic.SelectionController.prototype.getIndexAfter = function(index) {
    715   return this.layoutModel_.getHorizontalAdjacentIndex(index, 1);
    716 };
    717 
    718 /** @override */
    719 Mosaic.SelectionController.prototype.getIndexAbove = function(index) {
    720   return this.layoutModel_.getVerticalAdjacentIndex(index, -1);
    721 };
    722 
    723 /** @override */
    724 Mosaic.SelectionController.prototype.getIndexBelow = function(index) {
    725   return this.layoutModel_.getVerticalAdjacentIndex(index, 1);
    726 };
    727 
    728 ////////////////////////////////////////////////////////////////////////////////
    729 
    730 /**
    731  * Mosaic layout.
    732  *
    733  * @param {string=} opt_mode Layout mode.
    734  * @param {Mosaic.Density=} opt_maxDensity Layout density.
    735  * @constructor
    736  */
    737 Mosaic.Layout = function(opt_mode, opt_maxDensity) {
    738   this.mode_ = opt_mode || Mosaic.Layout.MODE_TENTATIVE;
    739   this.maxDensity_ = opt_maxDensity || Mosaic.Density.createHighest();
    740   this.reset_();
    741 };
    742 
    743 /**
    744  * Blank space at the top of the mosaic element. We do not do that in CSS
    745  * to make transition effects easier.
    746  */
    747 Mosaic.Layout.PADDING_TOP = 50;
    748 
    749 /**
    750  * Blank space at the bottom of the mosaic element.
    751  */
    752 Mosaic.Layout.PADDING_BOTTOM = 50;
    753 
    754 /**
    755  * Horizontal and vertical spacing between images. Should be kept in sync
    756  * with the style of .mosaic-item in gallery.css (= 2 * ( 4 + 1))
    757  */
    758 Mosaic.Layout.SPACING = 10;
    759 
    760 /**
    761  * Margin for scrolling using keyboard. Distance between a selected tile
    762  * and window border.
    763  */
    764 Mosaic.Layout.SCROLL_MARGIN = 30;
    765 
    766 /**
    767  * Layout mode: commit to DOM immediately.
    768  */
    769 Mosaic.Layout.MODE_FINAL = 'final';
    770 
    771 /**
    772  * Layout mode: do not commit layout to DOM until it is complete or the viewport
    773  * overflows.
    774  */
    775 Mosaic.Layout.MODE_TENTATIVE = 'tentative';
    776 
    777 /**
    778  * Layout mode: never commit layout to DOM.
    779  */
    780 Mosaic.Layout.MODE_DRY_RUN = 'dry_run';
    781 
    782 /**
    783  * Resets the layout.
    784  *
    785  * @private
    786  */
    787 Mosaic.Layout.prototype.reset_ = function() {
    788   this.columns_ = [];
    789   this.newColumn_ = null;
    790   this.density_ = Mosaic.Density.createLowest();
    791   if (this.mode_ !== Mosaic.Layout.MODE_DRY_RUN)  // DRY_RUN is sticky.
    792     this.mode_ = Mosaic.Layout.MODE_TENTATIVE;
    793 };
    794 
    795 /**
    796  * @param {number} width Viewport width.
    797  * @param {number} height Viewport height.
    798  */
    799 Mosaic.Layout.prototype.setViewportSize = function(width, height) {
    800   this.viewportWidth_ = width;
    801   this.viewportHeight_ = height;
    802   this.reset_();
    803 };
    804 
    805 /**
    806  * @return {number} Total width of the layout.
    807  */
    808 Mosaic.Layout.prototype.getWidth = function() {
    809   var lastColumn = this.getLastColumn_();
    810   return lastColumn ? lastColumn.getRight() : 0;
    811 };
    812 
    813 /**
    814  * @return {number} Total height of the layout.
    815  */
    816 Mosaic.Layout.prototype.getHeight = function() {
    817   var firstColumn = this.columns_[0];
    818   return firstColumn ? firstColumn.getHeight() : 0;
    819 };
    820 
    821 /**
    822  * @return {Array.<Mosaic.Tile>} All tiles in the layout.
    823  */
    824 Mosaic.Layout.prototype.getTiles = function() {
    825   return Array.prototype.concat.apply([],
    826       this.columns_.map(function(c) { return c.getTiles() }));
    827 };
    828 
    829 /**
    830  * @return {number} Total number of tiles added to the layout.
    831  */
    832 Mosaic.Layout.prototype.getTileCount = function() {
    833   return this.getLaidOutTileCount() +
    834       (this.newColumn_ ? this.newColumn_.getTileCount() : 0);
    835 };
    836 
    837 /**
    838  * @return {Mosaic.Column} The last column or null for empty layout.
    839  * @private
    840  */
    841 Mosaic.Layout.prototype.getLastColumn_ = function() {
    842   return this.columns_.length ? this.columns_[this.columns_.length - 1] : null;
    843 };
    844 
    845 /**
    846  * @return {number} Total number of tiles in completed columns.
    847  */
    848 Mosaic.Layout.prototype.getLaidOutTileCount = function() {
    849   var lastColumn = this.getLastColumn_();
    850   return lastColumn ? lastColumn.getNextTileIndex() : 0;
    851 };
    852 
    853 /**
    854  * Adds a tile to the layout.
    855  *
    856  * @param {Mosaic.Tile} tile The tile to be added.
    857  * @param {boolean} isLast True if this tile is the last.
    858  */
    859 Mosaic.Layout.prototype.add = function(tile, isLast) {
    860   var layoutQueue = [tile];
    861 
    862   // There are two levels of backtracking in the layout algorithm.
    863   // |Mosaic.Layout.density_| tracks the state of the 'global' backtracking
    864   // which aims to use as much of the viewport space as possible.
    865   // It starts with the lowest density and increases it until the layout
    866   // fits into the viewport. If it does not fit even at the highest density,
    867   // the layout continues with the highest density.
    868   //
    869   // |Mosaic.Column.density_| tracks the state of the 'local' backtracking
    870   // which aims to avoid producing unnaturally looking columns.
    871   // It starts with the current global density and decreases it until the column
    872   // looks nice.
    873 
    874   while (layoutQueue.length) {
    875     if (!this.newColumn_) {
    876       var lastColumn = this.getLastColumn_();
    877       this.newColumn_ = new Mosaic.Column(
    878           this.columns_.length,
    879           lastColumn ? lastColumn.getNextRowIndex() : 0,
    880           lastColumn ? lastColumn.getNextTileIndex() : 0,
    881           lastColumn ? lastColumn.getRight() : 0,
    882           this.viewportHeight_,
    883           this.density_.clone());
    884     }
    885 
    886     this.newColumn_.add(layoutQueue.shift());
    887 
    888     var isFinalColumn = isLast && !layoutQueue.length;
    889 
    890     if (!this.newColumn_.prepareLayout(isFinalColumn))
    891       continue; // Column is incomplete.
    892 
    893     if (this.newColumn_.isSuboptimal()) {
    894       layoutQueue = this.newColumn_.getTiles().concat(layoutQueue);
    895       this.newColumn_.retryWithLowerDensity();
    896       continue;
    897     }
    898 
    899     this.columns_.push(this.newColumn_);
    900     this.newColumn_ = null;
    901 
    902     if (this.mode_ === Mosaic.Layout.MODE_FINAL) {
    903       this.getLastColumn_().layout();
    904       continue;
    905     }
    906 
    907     if (this.getWidth() > this.viewportWidth_) {
    908       // Viewport completely filled.
    909       if (this.density_.equals(this.maxDensity_)) {
    910         // Max density reached, commit if tentative, just continue if dry run.
    911         if (this.mode_ === Mosaic.Layout.MODE_TENTATIVE)
    912           this.commit_();
    913         continue;
    914       }
    915 
    916       // Rollback the entire layout, retry with higher density.
    917       layoutQueue = this.getTiles().concat(layoutQueue);
    918       this.columns_ = [];
    919       this.density_.increase();
    920       continue;
    921     }
    922 
    923     if (isFinalColumn && this.mode_ === Mosaic.Layout.MODE_TENTATIVE) {
    924       // The complete tentative layout fits into the viewport.
    925       var stretched = this.findHorizontalLayout_();
    926       if (stretched)
    927         this.columns_ = stretched.columns_;
    928       // Center the layout in the viewport and commit.
    929       this.commit_((this.viewportWidth_ - this.getWidth()) / 2,
    930                    (this.viewportHeight_ - this.getHeight()) / 2);
    931     }
    932   }
    933 };
    934 
    935 /**
    936  * Commits the tentative layout.
    937  *
    938  * @param {number=} opt_offsetX Horizontal offset.
    939  * @param {number=} opt_offsetY Vertical offset.
    940  * @private
    941  */
    942 Mosaic.Layout.prototype.commit_ = function(opt_offsetX, opt_offsetY) {
    943   console.assert(this.mode_ !== Mosaic.Layout.MODE_FINAL,
    944       'Did not expect final layout');
    945   for (var i = 0; i !== this.columns_.length; i++) {
    946     this.columns_[i].layout(opt_offsetX, opt_offsetY);
    947   }
    948   this.mode_ = Mosaic.Layout.MODE_FINAL;
    949 };
    950 
    951 /**
    952  * Finds the most horizontally stretched layout built from the same tiles.
    953  *
    954  * The main layout algorithm fills the entire available viewport height.
    955  * If there is too few tiles this results in a layout that is unnaturally
    956  * stretched in the vertical direction.
    957  *
    958  * This method tries a number of smaller heights and returns the most
    959  * horizontally stretched layout that still fits into the viewport.
    960  *
    961  * @return {Mosaic.Layout} A horizontally stretched layout.
    962  * @private
    963  */
    964 Mosaic.Layout.prototype.findHorizontalLayout_ = function() {
    965   // If the layout aspect ratio is not dramatically different from
    966   // the viewport aspect ratio then there is no need to optimize.
    967   if (this.getWidth() / this.getHeight() >
    968       this.viewportWidth_ / this.viewportHeight_ * 0.9)
    969     return null;
    970 
    971   var tiles = this.getTiles();
    972   if (tiles.length === 1)
    973     return null;  // Single tile layout is always the same.
    974 
    975   var tileHeights = tiles.map(function(t) { return t.getMaxContentHeight() });
    976   var minTileHeight = Math.min.apply(null, tileHeights);
    977 
    978   for (var h = minTileHeight; h < this.viewportHeight_; h += minTileHeight) {
    979     var layout = new Mosaic.Layout(
    980         Mosaic.Layout.MODE_DRY_RUN, this.density_.clone());
    981     layout.setViewportSize(this.viewportWidth_, h);
    982     for (var t = 0; t !== tiles.length; t++)
    983       layout.add(tiles[t], t + 1 === tiles.length);
    984 
    985     if (layout.getWidth() <= this.viewportWidth_)
    986       return layout;
    987   }
    988 
    989   return null;
    990 };
    991 
    992 /**
    993  * Invalidates the layout after the given tile was modified (added, deleted or
    994  * changed dimensions).
    995  *
    996  * @param {number} index Tile index.
    997  * @private
    998  */
    999 Mosaic.Layout.prototype.invalidateFromTile_ = function(index) {
   1000   var columnIndex = this.getColumnIndexByTile_(index);
   1001   if (columnIndex < 0)
   1002     return; // Index not in the layout, probably already invalidated.
   1003 
   1004   if (this.columns_[columnIndex].getLeft() >= this.viewportWidth_) {
   1005     // The columns to the right cover the entire viewport width, so there is no
   1006     // chance that the modified layout would fit into the viewport.
   1007     // No point in restarting the entire layout, keep the columns to the right.
   1008     console.assert(this.mode_ === Mosaic.Layout.MODE_FINAL,
   1009         'Expected FINAL layout mode');
   1010     this.columns_ = this.columns_.slice(0, columnIndex);
   1011     this.newColumn_ = null;
   1012   } else {
   1013     // There is a chance that the modified layout would fit into the viewport.
   1014     this.reset_();
   1015     this.mode_ = Mosaic.Layout.MODE_TENTATIVE;
   1016   }
   1017 };
   1018 
   1019 /**
   1020  * Gets the index of the tile to the left or to the right from the given tile.
   1021  *
   1022  * @param {number} index Tile index.
   1023  * @param {number} direction -1 for left, 1 for right.
   1024  * @return {number} Adjacent tile index.
   1025  */
   1026 Mosaic.Layout.prototype.getHorizontalAdjacentIndex = function(
   1027     index, direction) {
   1028   var column = this.getColumnIndexByTile_(index);
   1029   if (column < 0) {
   1030     console.error('Cannot find column for tile #' + index);
   1031     return -1;
   1032   }
   1033 
   1034   var row = this.columns_[column].getRowByTileIndex(index);
   1035   if (!row) {
   1036     console.error('Cannot find row for tile #' + index);
   1037     return -1;
   1038   }
   1039 
   1040   var sameRowNeighbourIndex = index + direction;
   1041   if (row.hasTile(sameRowNeighbourIndex))
   1042     return sameRowNeighbourIndex;
   1043 
   1044   var adjacentColumn = column + direction;
   1045   if (adjacentColumn < 0 || adjacentColumn === this.columns_.length)
   1046     return -1;
   1047 
   1048   return this.columns_[adjacentColumn].
   1049       getEdgeTileIndex_(row.getCenterY(), -direction);
   1050 };
   1051 
   1052 /**
   1053  * Gets the index of the tile to the top or to the bottom from the given tile.
   1054  *
   1055  * @param {number} index Tile index.
   1056  * @param {number} direction -1 for above, 1 for below.
   1057  * @return {number} Adjacent tile index.
   1058  */
   1059 Mosaic.Layout.prototype.getVerticalAdjacentIndex = function(
   1060     index, direction) {
   1061   var column = this.getColumnIndexByTile_(index);
   1062   if (column < 0) {
   1063     console.error('Cannot find column for tile #' + index);
   1064     return -1;
   1065   }
   1066 
   1067   var row = this.columns_[column].getRowByTileIndex(index);
   1068   if (!row) {
   1069     console.error('Cannot find row for tile #' + index);
   1070     return -1;
   1071   }
   1072 
   1073   // Find the first item in the next row, or the last item in the previous row.
   1074   var adjacentRowNeighbourIndex =
   1075       row.getEdgeTileIndex_(direction) + direction;
   1076 
   1077   if (adjacentRowNeighbourIndex < 0 ||
   1078       adjacentRowNeighbourIndex > this.getTileCount() - 1)
   1079     return -1;
   1080 
   1081   if (!this.columns_[column].hasTile(adjacentRowNeighbourIndex)) {
   1082     // It is not in the current column, so return it.
   1083     return adjacentRowNeighbourIndex;
   1084   } else {
   1085     // It is in the current column, so we have to find optically the closest
   1086     // tile in the adjacent row.
   1087     var adjacentRow = this.columns_[column].getRowByTileIndex(
   1088         adjacentRowNeighbourIndex);
   1089     var previousTileCenterX = row.getTileByIndex(index).getCenterX();
   1090 
   1091     // Find the closest one.
   1092     var closestIndex = -1;
   1093     var closestDistance;
   1094     var adjacentRowTiles = adjacentRow.getTiles();
   1095     for (var t = 0; t !== adjacentRowTiles.length; t++) {
   1096       var distance =
   1097           Math.abs(adjacentRowTiles[t].getCenterX() - previousTileCenterX);
   1098       if (closestIndex === -1 || distance < closestDistance) {
   1099         closestIndex = adjacentRow.getEdgeTileIndex_(-1) + t;
   1100         closestDistance = distance;
   1101       }
   1102     }
   1103     return closestIndex;
   1104   }
   1105 };
   1106 
   1107 /**
   1108  * @param {number} index Tile index.
   1109  * @return {number} Index of the column containing the given tile.
   1110  * @private
   1111  */
   1112 Mosaic.Layout.prototype.getColumnIndexByTile_ = function(index) {
   1113   for (var c = 0; c !== this.columns_.length; c++) {
   1114     if (this.columns_[c].hasTile(index))
   1115       return c;
   1116   }
   1117   return -1;
   1118 };
   1119 
   1120 /**
   1121  * Scales the given array of size values to satisfy 3 conditions:
   1122  * 1. The new sizes must be integer.
   1123  * 2. The new sizes must sum up to the given |total| value.
   1124  * 3. The relative proportions of the sizes should be as close to the original
   1125  *    as possible.
   1126  *
   1127  * @param {Array.<number>} sizes Array of sizes.
   1128  * @param {number} newTotal New total size.
   1129  */
   1130 Mosaic.Layout.rescaleSizesToNewTotal = function(sizes, newTotal) {
   1131   var total = 0;
   1132 
   1133   var partialTotals = [0];
   1134   for (var i = 0; i !== sizes.length; i++) {
   1135     total += sizes[i];
   1136     partialTotals.push(total);
   1137   }
   1138 
   1139   var scale = newTotal / total;
   1140 
   1141   for (i = 0; i !== sizes.length; i++) {
   1142     sizes[i] = Math.round(partialTotals[i + 1] * scale) -
   1143         Math.round(partialTotals[i] * scale);
   1144   }
   1145 };
   1146 
   1147 ////////////////////////////////////////////////////////////////////////////////
   1148 
   1149 /**
   1150  * Representation of the layout density.
   1151  *
   1152  * @param {number} horizontal Horizontal density, number tiles per row.
   1153  * @param {number} vertical Vertical density, frequency of rows forced to
   1154  *   contain a single tile.
   1155  * @constructor
   1156  */
   1157 Mosaic.Density = function(horizontal, vertical) {
   1158   this.horizontal = horizontal;
   1159   this.vertical = vertical;
   1160 };
   1161 
   1162 /**
   1163  * Minimal horizontal density (tiles per row).
   1164  */
   1165 Mosaic.Density.MIN_HORIZONTAL = 1;
   1166 
   1167 /**
   1168  * Minimal horizontal density (tiles per row).
   1169  */
   1170 Mosaic.Density.MAX_HORIZONTAL = 3;
   1171 
   1172 /**
   1173  * Minimal vertical density: force 1 out of 2 rows to containt a single tile.
   1174  */
   1175 Mosaic.Density.MIN_VERTICAL = 2;
   1176 
   1177 /**
   1178  * Maximal vertical density: force 1 out of 3 rows to containt a single tile.
   1179  */
   1180 Mosaic.Density.MAX_VERTICAL = 3;
   1181 
   1182 /**
   1183  * @return {Mosaic.Density} Lowest density.
   1184  */
   1185 Mosaic.Density.createLowest = function() {
   1186   return new Mosaic.Density(
   1187       Mosaic.Density.MIN_HORIZONTAL,
   1188       Mosaic.Density.MIN_VERTICAL /* ignored when horizontal is at min */);
   1189 };
   1190 
   1191 /**
   1192  * @return {Mosaic.Density} Highest density.
   1193  */
   1194 Mosaic.Density.createHighest = function() {
   1195   return new Mosaic.Density(
   1196       Mosaic.Density.MAX_HORIZONTAL,
   1197       Mosaic.Density.MAX_VERTICAL);
   1198 };
   1199 
   1200 /**
   1201  * @return {Mosaic.Density} A clone of this density object.
   1202  */
   1203 Mosaic.Density.prototype.clone = function() {
   1204   return new Mosaic.Density(this.horizontal, this.vertical);
   1205 };
   1206 
   1207 /**
   1208  * @param {Mosaic.Density} that The other object.
   1209  * @return {boolean} True if equal.
   1210  */
   1211 Mosaic.Density.prototype.equals = function(that) {
   1212   return this.horizontal === that.horizontal &&
   1213          this.vertical === that.vertical;
   1214 };
   1215 
   1216 /**
   1217  * Increases the density to the next level.
   1218  */
   1219 Mosaic.Density.prototype.increase = function() {
   1220   if (this.horizontal === Mosaic.Density.MIN_HORIZONTAL ||
   1221       this.vertical === Mosaic.Density.MAX_VERTICAL) {
   1222     console.assert(this.horizontal < Mosaic.Density.MAX_HORIZONTAL);
   1223     this.horizontal++;
   1224     this.vertical = Mosaic.Density.MIN_VERTICAL;
   1225   } else {
   1226     this.vertical++;
   1227   }
   1228 };
   1229 
   1230 /**
   1231  * Decreases horizontal density.
   1232  */
   1233 Mosaic.Density.prototype.decreaseHorizontal = function() {
   1234   console.assert(this.horizontal > Mosaic.Density.MIN_HORIZONTAL);
   1235   this.horizontal--;
   1236 };
   1237 
   1238 /**
   1239  * @param {number} tileCount Number of tiles in the row.
   1240  * @param {number} rowIndex Global row index.
   1241  * @return {boolean} True if the row is complete.
   1242  */
   1243 Mosaic.Density.prototype.isRowComplete = function(tileCount, rowIndex) {
   1244   return (tileCount === this.horizontal) || (rowIndex % this.vertical) === 0;
   1245 };
   1246 
   1247 ////////////////////////////////////////////////////////////////////////////////
   1248 
   1249 /**
   1250  * A column in a mosaic layout. Contains rows.
   1251  *
   1252  * @param {number} index Column index.
   1253  * @param {number} firstRowIndex Global row index.
   1254  * @param {number} firstTileIndex Index of the first tile in the column.
   1255  * @param {number} left Left edge coordinate.
   1256  * @param {number} maxHeight Maximum height.
   1257  * @param {Mosaic.Density} density Layout density.
   1258  * @constructor
   1259  */
   1260 Mosaic.Column = function(index, firstRowIndex, firstTileIndex, left, maxHeight,
   1261                          density) {
   1262   this.index_ = index;
   1263   this.firstRowIndex_ = firstRowIndex;
   1264   this.firstTileIndex_ = firstTileIndex;
   1265   this.left_ = left;
   1266   this.maxHeight_ = maxHeight;
   1267   this.density_ = density;
   1268 
   1269   this.reset_();
   1270 };
   1271 
   1272 /**
   1273  * Resets the layout.
   1274  * @private
   1275  */
   1276 Mosaic.Column.prototype.reset_ = function() {
   1277   this.tiles_ = [];
   1278   this.rows_ = [];
   1279   this.newRow_ = null;
   1280 };
   1281 
   1282 /**
   1283  * @return {number} Number of tiles in the column.
   1284  */
   1285 Mosaic.Column.prototype.getTileCount = function() { return this.tiles_.length };
   1286 
   1287 /**
   1288  * @return {number} Index of the last tile + 1.
   1289  */
   1290 Mosaic.Column.prototype.getNextTileIndex = function() {
   1291   return this.firstTileIndex_ + this.getTileCount();
   1292 };
   1293 
   1294 /**
   1295  * @return {number} Global index of the last row + 1.
   1296  */
   1297 Mosaic.Column.prototype.getNextRowIndex = function() {
   1298   return this.firstRowIndex_ + this.rows_.length;
   1299 };
   1300 
   1301 /**
   1302  * @return {Array.<Mosaic.Tile>} Array of tiles in the column.
   1303  */
   1304 Mosaic.Column.prototype.getTiles = function() { return this.tiles_ };
   1305 
   1306 /**
   1307  * @param {number} index Tile index.
   1308  * @return {boolean} True if this column contains the tile with the given index.
   1309  */
   1310 Mosaic.Column.prototype.hasTile = function(index) {
   1311   return this.firstTileIndex_ <= index &&
   1312       index < (this.firstTileIndex_ + this.getTileCount());
   1313 };
   1314 
   1315 /**
   1316  * @param {number} y Y coordinate.
   1317  * @param {number} direction -1 for left, 1 for right.
   1318  * @return {number} Index of the tile lying on the edge of the column at the
   1319  *    given y coordinate.
   1320  * @private
   1321  */
   1322 Mosaic.Column.prototype.getEdgeTileIndex_ = function(y, direction) {
   1323   for (var r = 0; r < this.rows_.length; r++) {
   1324     if (this.rows_[r].coversY(y))
   1325       return this.rows_[r].getEdgeTileIndex_(direction);
   1326   }
   1327   return -1;
   1328 };
   1329 
   1330 /**
   1331  * @param {number} index Tile index.
   1332  * @return {Mosaic.Row} The row containing the tile with a given index.
   1333  */
   1334 Mosaic.Column.prototype.getRowByTileIndex = function(index) {
   1335   for (var r = 0; r !== this.rows_.length; r++)
   1336     if (this.rows_[r].hasTile(index))
   1337       return this.rows_[r];
   1338 
   1339   return null;
   1340 };
   1341 
   1342 /**
   1343  * Adds a tile to the column.
   1344  *
   1345  * @param {Mosaic.Tile} tile The tile to add.
   1346  */
   1347 Mosaic.Column.prototype.add = function(tile) {
   1348   var rowIndex = this.getNextRowIndex();
   1349 
   1350   if (!this.newRow_)
   1351      this.newRow_ = new Mosaic.Row(this.getNextTileIndex());
   1352 
   1353   this.tiles_.push(tile);
   1354   this.newRow_.add(tile);
   1355 
   1356   if (this.density_.isRowComplete(this.newRow_.getTileCount(), rowIndex)) {
   1357     this.rows_.push(this.newRow_);
   1358     this.newRow_ = null;
   1359   }
   1360 };
   1361 
   1362 /**
   1363  * Prepares the column layout.
   1364  *
   1365  * @param {boolean=} opt_force True if the layout must be performed even for an
   1366  *   incomplete column.
   1367  * @return {boolean} True if the layout was performed.
   1368  */
   1369 Mosaic.Column.prototype.prepareLayout = function(opt_force) {
   1370   if (opt_force && this.newRow_) {
   1371     this.rows_.push(this.newRow_);
   1372     this.newRow_ = null;
   1373   }
   1374 
   1375   if (this.rows_.length === 0)
   1376     return false;
   1377 
   1378   this.width_ = Math.min.apply(
   1379       null, this.rows_.map(function(row) { return row.getMaxWidth() }));
   1380 
   1381   this.height_ = 0;
   1382 
   1383   this.rowHeights_ = [];
   1384   for (var r = 0; r !== this.rows_.length; r++) {
   1385     var rowHeight = this.rows_[r].getHeightForWidth(this.width_);
   1386     this.height_ += rowHeight;
   1387     this.rowHeights_.push(rowHeight);
   1388   }
   1389 
   1390   var overflow = this.height_ / this.maxHeight_;
   1391   if (!opt_force && (overflow < 1))
   1392     return false;
   1393 
   1394   if (overflow > 1) {
   1395     // Scale down the column width and height.
   1396     this.width_ = Math.round(this.width_ / overflow);
   1397     this.height_ = this.maxHeight_;
   1398     Mosaic.Layout.rescaleSizesToNewTotal(this.rowHeights_, this.maxHeight_);
   1399   }
   1400 
   1401   return true;
   1402 };
   1403 
   1404 /**
   1405  * Retries the column layout with less tiles per row.
   1406  */
   1407 Mosaic.Column.prototype.retryWithLowerDensity = function() {
   1408   this.density_.decreaseHorizontal();
   1409   this.reset_();
   1410 };
   1411 
   1412 /**
   1413  * @return {number} Column left edge coordinate.
   1414  */
   1415 Mosaic.Column.prototype.getLeft = function() { return this.left_ };
   1416 
   1417 /**
   1418  * @return {number} Column right edge coordinate after the layout.
   1419  */
   1420 Mosaic.Column.prototype.getRight = function() {
   1421   return this.left_ + this.width_;
   1422 };
   1423 
   1424 /**
   1425  * @return {number} Column height after the layout.
   1426  */
   1427 Mosaic.Column.prototype.getHeight = function() { return this.height_ };
   1428 
   1429 /**
   1430  * Performs the column layout.
   1431  * @param {number=} opt_offsetX Horizontal offset.
   1432  * @param {number=} opt_offsetY Vertical offset.
   1433  */
   1434 Mosaic.Column.prototype.layout = function(opt_offsetX, opt_offsetY) {
   1435   opt_offsetX = opt_offsetX || 0;
   1436   opt_offsetY = opt_offsetY || 0;
   1437   var rowTop = Mosaic.Layout.PADDING_TOP;
   1438   for (var r = 0; r !== this.rows_.length; r++) {
   1439     this.rows_[r].layout(
   1440         opt_offsetX + this.left_,
   1441         opt_offsetY + rowTop,
   1442         this.width_,
   1443         this.rowHeights_[r]);
   1444     rowTop += this.rowHeights_[r];
   1445   }
   1446 };
   1447 
   1448 /**
   1449  * Checks if the column layout is too ugly to be displayed.
   1450  *
   1451  * @return {boolean} True if the layout is suboptimal.
   1452  */
   1453 Mosaic.Column.prototype.isSuboptimal = function() {
   1454   var tileCounts =
   1455       this.rows_.map(function(row) { return row.getTileCount() });
   1456 
   1457   var maxTileCount = Math.max.apply(null, tileCounts);
   1458   if (maxTileCount === 1)
   1459     return false;  // Every row has exactly 1 tile, as optimal as it gets.
   1460 
   1461   var sizes =
   1462       this.tiles_.map(function(tile) { return tile.getMaxContentHeight() });
   1463 
   1464   // Ugly layout #1: all images are small and some are one the same row.
   1465   var allSmall = Math.max.apply(null, sizes) <= Mosaic.Tile.SMALL_IMAGE_SIZE;
   1466   if (allSmall)
   1467     return true;
   1468 
   1469   // Ugly layout #2: all images are large and none occupies an entire row.
   1470   var allLarge = Math.min.apply(null, sizes) > Mosaic.Tile.SMALL_IMAGE_SIZE;
   1471   var allCombined = Math.min.apply(null, tileCounts) !== 1;
   1472   if (allLarge && allCombined)
   1473     return true;
   1474 
   1475   // Ugly layout #3: some rows have too many tiles for the resulting width.
   1476   if (this.width_ / maxTileCount < 100)
   1477     return true;
   1478 
   1479   return false;
   1480 };
   1481 
   1482 ////////////////////////////////////////////////////////////////////////////////
   1483 
   1484 /**
   1485  * A row in a mosaic layout. Contains tiles.
   1486  *
   1487  * @param {number} firstTileIndex Index of the first tile in the row.
   1488  * @constructor
   1489  */
   1490 Mosaic.Row = function(firstTileIndex) {
   1491   this.firstTileIndex_ = firstTileIndex;
   1492   this.tiles_ = [];
   1493 };
   1494 
   1495 /**
   1496  * @param {Mosaic.Tile} tile The tile to add.
   1497  */
   1498 Mosaic.Row.prototype.add = function(tile) {
   1499   console.assert(this.getTileCount() < Mosaic.Density.MAX_HORIZONTAL);
   1500   this.tiles_.push(tile);
   1501 };
   1502 
   1503 /**
   1504  * @return {Array.<Mosaic.Tile>} Array of tiles in the row.
   1505  */
   1506 Mosaic.Row.prototype.getTiles = function() { return this.tiles_ };
   1507 
   1508 /**
   1509  * Gets a tile by index.
   1510  * @param {number} index Tile index.
   1511  * @return {Mosaic.Tile} Requested tile or null if not found.
   1512  */
   1513 Mosaic.Row.prototype.getTileByIndex = function(index) {
   1514   if (!this.hasTile(index))
   1515     return null;
   1516   return this.tiles_[index - this.firstTileIndex_];
   1517 };
   1518 
   1519 /**
   1520  *
   1521  * @return {number} Number of tiles in the row.
   1522  */
   1523 Mosaic.Row.prototype.getTileCount = function() { return this.tiles_.length };
   1524 
   1525 /**
   1526  * @param {number} index Tile index.
   1527  * @return {boolean} True if this row contains the tile with the given index.
   1528  */
   1529 Mosaic.Row.prototype.hasTile = function(index) {
   1530   return this.firstTileIndex_ <= index &&
   1531       index < (this.firstTileIndex_ + this.tiles_.length);
   1532 };
   1533 
   1534 /**
   1535  * @param {number} y Y coordinate.
   1536  * @return {boolean} True if this row covers the given Y coordinate.
   1537  */
   1538 Mosaic.Row.prototype.coversY = function(y) {
   1539   return this.top_ <= y && y < (this.top_ + this.height_);
   1540 };
   1541 
   1542 /**
   1543  * @return {number} Y coordinate of the tile center.
   1544  */
   1545 Mosaic.Row.prototype.getCenterY = function() {
   1546   return this.top_ + Math.round(this.height_ / 2);
   1547 };
   1548 
   1549 /**
   1550  * Gets the first or the last tile.
   1551  *
   1552  * @param {number} direction -1 for the first tile, 1 for the last tile.
   1553  * @return {number} Tile index.
   1554  * @private
   1555  */
   1556 Mosaic.Row.prototype.getEdgeTileIndex_ = function(direction) {
   1557   if (direction < 0)
   1558     return this.firstTileIndex_;
   1559   else
   1560     return this.firstTileIndex_ + this.getTileCount() - 1;
   1561 };
   1562 
   1563 /**
   1564  * @return {number} Aspect ration of the combined content box of this row.
   1565  * @private
   1566  */
   1567 Mosaic.Row.prototype.getTotalContentAspectRatio_ = function() {
   1568   var sum = 0;
   1569   for (var t = 0; t !== this.tiles_.length; t++)
   1570     sum += this.tiles_[t].getAspectRatio();
   1571   return sum;
   1572 };
   1573 
   1574 /**
   1575  * @return {number} Total horizontal spacing in this row. This includes
   1576  *   the spacing between the tiles and both left and right margins.
   1577  *
   1578  * @private
   1579  */
   1580 Mosaic.Row.prototype.getTotalHorizontalSpacing_ = function() {
   1581   return Mosaic.Layout.SPACING * this.getTileCount();
   1582 };
   1583 
   1584 /**
   1585  * @return {number} Maximum width that this row may have without overscaling
   1586  * any of the tiles.
   1587  */
   1588 Mosaic.Row.prototype.getMaxWidth = function() {
   1589   var contentHeight = Math.min.apply(null,
   1590       this.tiles_.map(function(tile) { return tile.getMaxContentHeight() }));
   1591 
   1592   var contentWidth =
   1593       Math.round(contentHeight * this.getTotalContentAspectRatio_());
   1594   return contentWidth + this.getTotalHorizontalSpacing_();
   1595 };
   1596 
   1597 /**
   1598  * Computes the height that best fits the supplied row width given
   1599  * aspect ratios of the tiles in this row.
   1600  *
   1601  * @param {number} width Row width.
   1602  * @return {number} Height.
   1603  */
   1604 Mosaic.Row.prototype.getHeightForWidth = function(width) {
   1605   var contentWidth = width - this.getTotalHorizontalSpacing_();
   1606   var contentHeight =
   1607       Math.round(contentWidth / this.getTotalContentAspectRatio_());
   1608   return contentHeight + Mosaic.Layout.SPACING;
   1609 };
   1610 
   1611 /**
   1612  * Positions the row in the mosaic.
   1613  *
   1614  * @param {number} left Left position.
   1615  * @param {number} top Top position.
   1616  * @param {number} width Width.
   1617  * @param {number} height Height.
   1618  */
   1619 Mosaic.Row.prototype.layout = function(left, top, width, height) {
   1620   this.top_ = top;
   1621   this.height_ = height;
   1622 
   1623   var contentWidth = width - this.getTotalHorizontalSpacing_();
   1624   var contentHeight = height - Mosaic.Layout.SPACING;
   1625 
   1626   var tileContentWidth = this.tiles_.map(
   1627       function(tile) { return tile.getAspectRatio() });
   1628 
   1629   Mosaic.Layout.rescaleSizesToNewTotal(tileContentWidth, contentWidth);
   1630 
   1631   var tileLeft = left;
   1632   for (var t = 0; t !== this.tiles_.length; t++) {
   1633     var tileWidth = tileContentWidth[t] + Mosaic.Layout.SPACING;
   1634     this.tiles_[t].layout(tileLeft, top, tileWidth, height);
   1635     tileLeft += tileWidth;
   1636   }
   1637 };
   1638 
   1639 ////////////////////////////////////////////////////////////////////////////////
   1640 
   1641 /**
   1642  * A single tile of the image mosaic.
   1643  *
   1644  * @param {Element} container Container element.
   1645  * @param {Gallery.Item} item Gallery item associated with this tile.
   1646  * @param {EntryLocation} locationInfo Location information for the tile.
   1647  * @return {Element} The new tile element.
   1648  * @constructor
   1649  */
   1650 Mosaic.Tile = function(container, item, locationInfo) {
   1651   var self = container.ownerDocument.createElement('div');
   1652   Mosaic.Tile.decorate(self, container, item, locationInfo);
   1653   return self;
   1654 };
   1655 
   1656 /**
   1657  * @param {Element} self Self pointer.
   1658  * @param {Element} container Container element.
   1659  * @param {Gallery.Item} item Gallery item associated with this tile.
   1660  * @param {EntryLocation} locationInfo Location info for the tile image.
   1661  */
   1662 Mosaic.Tile.decorate = function(self, container, item, locationInfo) {
   1663   self.__proto__ = Mosaic.Tile.prototype;
   1664   self.className = 'mosaic-tile';
   1665 
   1666   self.container_ = container;
   1667   self.item_ = item;
   1668   self.left_ = null; // Mark as not laid out.
   1669   self.hidpiEmbedded_ = locationInfo && locationInfo.isDriveBased;
   1670 };
   1671 
   1672 /**
   1673  * Load mode for the tile's image.
   1674  * @enum {number}
   1675  */
   1676 Mosaic.Tile.LoadMode = {
   1677   LOW_DPI: 0,
   1678   HIGH_DPI: 1
   1679 };
   1680 
   1681 /**
   1682 * Inherit from HTMLDivElement.
   1683 */
   1684 Mosaic.Tile.prototype.__proto__ = HTMLDivElement.prototype;
   1685 
   1686 /**
   1687  * Minimum tile content size.
   1688  */
   1689 Mosaic.Tile.MIN_CONTENT_SIZE = 64;
   1690 
   1691 /**
   1692  * Maximum tile content size.
   1693  */
   1694 Mosaic.Tile.MAX_CONTENT_SIZE = 512;
   1695 
   1696 /**
   1697  * Default size for a tile with no thumbnail image.
   1698  */
   1699 Mosaic.Tile.GENERIC_ICON_SIZE = 128;
   1700 
   1701 /**
   1702  * Max size of an image considered to be 'small'.
   1703  * Small images are laid out slightly differently.
   1704  */
   1705 Mosaic.Tile.SMALL_IMAGE_SIZE = 160;
   1706 
   1707 /**
   1708  * @return {Gallery.Item} The Gallery item.
   1709  */
   1710 Mosaic.Tile.prototype.getItem = function() { return this.item_; };
   1711 
   1712 /**
   1713  * @return {number} Maximum content height that this tile can have.
   1714  */
   1715 Mosaic.Tile.prototype.getMaxContentHeight = function() {
   1716   return this.maxContentHeight_;
   1717 };
   1718 
   1719 /**
   1720  * @return {number} The aspect ratio of the tile image.
   1721  */
   1722 Mosaic.Tile.prototype.getAspectRatio = function() { return this.aspectRatio_; };
   1723 
   1724 /**
   1725  * @return {boolean} True if the tile is initialized.
   1726  */
   1727 Mosaic.Tile.prototype.isInitialized = function() {
   1728   return !!this.maxContentHeight_;
   1729 };
   1730 
   1731 /**
   1732  * Checks whether the image of specified (or better resolution) has been loaded.
   1733  *
   1734  * @param {Mosaic.Tile.LoadMode=} opt_loadMode Loading mode, default: LOW_DPI.
   1735  * @return {boolean} True if the tile is loaded with the specified dpi or
   1736  *     better.
   1737  */
   1738 Mosaic.Tile.prototype.isLoaded = function(opt_loadMode) {
   1739   var loadMode = opt_loadMode || Mosaic.Tile.LoadMode.LOW_DPI;
   1740   switch (loadMode) {
   1741     case Mosaic.Tile.LoadMode.LOW_DPI:
   1742       if (this.imagePreloaded_ || this.imageLoaded_)
   1743         return true;
   1744       break;
   1745     case Mosaic.Tile.LoadMode.HIGH_DPI:
   1746       if (this.imageLoaded_)
   1747         return true;
   1748       break;
   1749   }
   1750   return false;
   1751 };
   1752 
   1753 /**
   1754  * Checks whether the image of specified (or better resolution) is being loaded.
   1755  *
   1756  * @param {Mosaic.Tile.LoadMode=} opt_loadMode Loading mode, default: LOW_DPI.
   1757  * @return {boolean} True if the tile is being loaded with the specified dpi or
   1758  *     better.
   1759  */
   1760 Mosaic.Tile.prototype.isLoading = function(opt_loadMode) {
   1761   var loadMode = opt_loadMode || Mosaic.Tile.LoadMode.LOW_DPI;
   1762   switch (loadMode) {
   1763     case Mosaic.Tile.LoadMode.LOW_DPI:
   1764       if (this.imagePreloading_ || this.imageLoading_)
   1765         return true;
   1766       break;
   1767     case Mosaic.Tile.LoadMode.HIGH_DPI:
   1768       if (this.imageLoading_)
   1769         return true;
   1770       break;
   1771   }
   1772   return false;
   1773 };
   1774 
   1775 /**
   1776  * Marks the tile as not loaded to prevent it from participating in the layout.
   1777  */
   1778 Mosaic.Tile.prototype.markUnloaded = function() {
   1779   this.maxContentHeight_ = 0;
   1780   if (this.thumbnailLoader_) {
   1781     this.thumbnailLoader_.cancel();
   1782     this.imagePreloaded_ = false;
   1783     this.imagePreloading_ = false;
   1784     this.imageLoaded_ = false;
   1785     this.imageLoading_ = false;
   1786   }
   1787 };
   1788 
   1789 /**
   1790  * Initializes the thumbnail in the tile. Does not load an image, but sets
   1791  * target dimensions using metadata.
   1792  *
   1793  * @param {Object} metadata Metadata object.
   1794  * @param {function()} onImageMeasured Image measured callback.
   1795  */
   1796 Mosaic.Tile.prototype.init = function(metadata, onImageMeasured) {
   1797   this.markUnloaded();
   1798   this.left_ = null;  // Mark as not laid out.
   1799 
   1800   // Set higher priority for the selected elements to load them first.
   1801   var priority = this.getAttribute('selected') ? 2 : 3;
   1802 
   1803   // Use embedded thumbnails on Drive, since they have higher resolution.
   1804   this.thumbnailLoader_ = new ThumbnailLoader(
   1805       this.getItem().getEntry(),
   1806       ThumbnailLoader.LoaderType.CANVAS,
   1807       metadata,
   1808       undefined,  // Media type.
   1809       this.hidpiEmbedded_ ? ThumbnailLoader.UseEmbedded.USE_EMBEDDED :
   1810                             ThumbnailLoader.UseEmbedded.NO_EMBEDDED,
   1811       priority);
   1812 
   1813   // If no hidpi embedded thumbnail available, then use the low resolution
   1814   // for preloading.
   1815   if (!this.hidpiEmbedded_) {
   1816     this.thumbnailPreloader_ = new ThumbnailLoader(
   1817         this.getItem().getEntry(),
   1818         ThumbnailLoader.LoaderType.CANVAS,
   1819         metadata,
   1820         undefined,  // Media type.
   1821         ThumbnailLoader.UseEmbedded.USE_EMBEDDED,
   1822         2);  // Preloaders have always higher priotity, so the preload images
   1823              // are loaded as soon as possible.
   1824   }
   1825 
   1826   var setDimensions = function(width, height) {
   1827     if (width > height) {
   1828       if (width > Mosaic.Tile.MAX_CONTENT_SIZE) {
   1829         height = Math.round(height * Mosaic.Tile.MAX_CONTENT_SIZE / width);
   1830         width = Mosaic.Tile.MAX_CONTENT_SIZE;
   1831       }
   1832     } else {
   1833       if (height > Mosaic.Tile.MAX_CONTENT_SIZE) {
   1834         width = Math.round(width * Mosaic.Tile.MAX_CONTENT_SIZE / height);
   1835         height = Mosaic.Tile.MAX_CONTENT_SIZE;
   1836       }
   1837     }
   1838     this.maxContentHeight_ = Math.max(Mosaic.Tile.MIN_CONTENT_SIZE, height);
   1839     this.aspectRatio_ = width / height;
   1840     onImageMeasured();
   1841   }.bind(this);
   1842 
   1843   // Dimensions are always acquired from the metadata. For local files, it is
   1844   // extracted from headers. For Drive files, it is received via the Drive API.
   1845   // If the dimensions are not available, then the fallback dimensions will be
   1846   // used (same as for the generic icon).
   1847   if (metadata.media && metadata.media.width) {
   1848     setDimensions(metadata.media.width, metadata.media.height);
   1849   } else if (metadata.drive && metadata.drive.imageWidth &&
   1850              metadata.drive.imageHeight) {
   1851     setDimensions(metadata.drive.imageWidth, metadata.drive.imageHeight);
   1852   } else {
   1853     // No dimensions in metadata, then use the generic dimensions.
   1854     setDimensions(Mosaic.Tile.GENERIC_ICON_SIZE,
   1855                   Mosaic.Tile.GENERIC_ICON_SIZE);
   1856   }
   1857 };
   1858 
   1859 /**
   1860  * Loads an image into the tile.
   1861  *
   1862  * The mode argument is a hint. Use low-dpi for faster response, and high-dpi
   1863  * for better output, but possibly affecting performance.
   1864  *
   1865  * If the mode is high-dpi, then a the high-dpi image is loaded, but also
   1866  * low-dpi image is loaded for preloading (if available).
   1867  * For the low-dpi mode, only low-dpi image is loaded. If not available, then
   1868  * the high-dpi image is loaded as a fallback.
   1869  *
   1870  * @param {Mosaic.Tile.LoadMode} loadMode Loading mode.
   1871  * @param {function(boolean)} onImageLoaded Callback when image is loaded.
   1872  *     The argument is true for success, false for failure.
   1873  */
   1874 Mosaic.Tile.prototype.load = function(loadMode, onImageLoaded) {
   1875   // Attaches the image to the tile and finalizes loading process for the
   1876   // specified loader.
   1877   var finalizeLoader = function(mode, success, loader) {
   1878     if (success && this.wrapper_) {
   1879       // Show the fade-in animation only when previously there was no image
   1880       // attached in this tile.
   1881       if (!this.imageLoaded_ && !this.imagePreloaded_)
   1882         this.wrapper_.classList.add('animated');
   1883       else
   1884         this.wrapper_.classList.remove('animated');
   1885     }
   1886     loader.attachImage(this.wrapper_, ThumbnailLoader.FillMode.OVER_FILL);
   1887     onImageLoaded(success);
   1888     switch (mode) {
   1889       case Mosaic.Tile.LoadMode.LOW_DPI:
   1890         this.imagePreloading_ = false;
   1891         this.imagePreloaded_ = true;
   1892         break;
   1893       case Mosaic.Tile.LoadMode.HIGH_DPI:
   1894         this.imageLoading_ = false;
   1895         this.imageLoaded_ = true;
   1896         break;
   1897     }
   1898   }.bind(this);
   1899 
   1900   // Always load the low-dpi image first if it is available for the fastest
   1901   // feedback.
   1902   if (!this.imagePreloading_ && this.thumbnailPreloader_) {
   1903     this.imagePreloading_ = true;
   1904     this.thumbnailPreloader_.loadDetachedImage(function(success) {
   1905       // Hi-dpi loaded first, ignore this call then.
   1906       if (this.imageLoaded_)
   1907         return;
   1908       finalizeLoader(Mosaic.Tile.LoadMode.LOW_DPI,
   1909                      success,
   1910                      this.thumbnailPreloader_);
   1911     }.bind(this));
   1912   }
   1913 
   1914   // Load the high-dpi image only when it is requested, or the low-dpi is not
   1915   // available.
   1916   if (!this.imageLoading_ &&
   1917       (loadMode === Mosaic.Tile.LoadMode.HIGH_DPI || !this.imagePreloading_)) {
   1918     this.imageLoading_ = true;
   1919     this.thumbnailLoader_.loadDetachedImage(function(success) {
   1920       // Cancel preloading, since the hi-dpi image is ready.
   1921       if (this.thumbnailPreloader_)
   1922         this.thumbnailPreloader_.cancel();
   1923       finalizeLoader(Mosaic.Tile.LoadMode.HIGH_DPI,
   1924                      success,
   1925                      this.thumbnailLoader_);
   1926     }.bind(this));
   1927   }
   1928 };
   1929 
   1930 /**
   1931  * Unloads an image from the tile.
   1932  */
   1933 Mosaic.Tile.prototype.unload = function() {
   1934   this.thumbnailLoader_.cancel();
   1935   if (this.thumbnailPreloader_)
   1936     this.thumbnailPreloader_.cancel();
   1937   this.imagePreloaded_ = false;
   1938   this.imageLoaded_ = false;
   1939   this.imagePreloading_ = false;
   1940   this.imageLoading_ = false;
   1941   this.wrapper_.innerText = '';
   1942 };
   1943 
   1944 /**
   1945  * Selects/unselects the tile.
   1946  *
   1947  * @param {boolean} on True if selected.
   1948  */
   1949 Mosaic.Tile.prototype.select = function(on) {
   1950   if (on)
   1951     this.setAttribute('selected', true);
   1952   else
   1953     this.removeAttribute('selected');
   1954 };
   1955 
   1956 /**
   1957  * Positions the tile in the mosaic.
   1958  *
   1959  * @param {number} left Left position.
   1960  * @param {number} top Top position.
   1961  * @param {number} width Width.
   1962  * @param {number} height Height.
   1963  */
   1964 Mosaic.Tile.prototype.layout = function(left, top, width, height) {
   1965   this.left_ = left;
   1966   this.top_ = top;
   1967   this.width_ = width;
   1968   this.height_ = height;
   1969 
   1970   this.style.left = left + 'px';
   1971   this.style.top = top + 'px';
   1972   this.style.width = width + 'px';
   1973   this.style.height = height + 'px';
   1974 
   1975   if (!this.wrapper_) {  // First time, create DOM.
   1976     this.container_.appendChild(this);
   1977     var border = util.createChild(this, 'img-border');
   1978     this.wrapper_ = util.createChild(border, 'img-wrapper');
   1979   }
   1980   if (this.hasAttribute('selected'))
   1981     this.scrollIntoView(false);
   1982 
   1983   if (this.imageLoaded_) {
   1984     this.thumbnailLoader_.attachImage(this.wrapper_,
   1985                                       ThumbnailLoader.FillMode.OVER_FILL);
   1986   }
   1987 };
   1988 
   1989 /**
   1990  * If the tile is not fully visible scroll the parent to make it fully visible.
   1991  * @param {boolean=} opt_animated True, if scroll should be animated,
   1992  *     default: true.
   1993  */
   1994 Mosaic.Tile.prototype.scrollIntoView = function(opt_animated) {
   1995   if (this.left_ === null)  // Not laid out.
   1996     return;
   1997 
   1998   var targetPosition;
   1999   var tileLeft = this.left_ - Mosaic.Layout.SCROLL_MARGIN;
   2000   if (tileLeft < this.container_.scrollLeft) {
   2001     targetPosition = tileLeft;
   2002   } else {
   2003     var tileRight = this.left_ + this.width_ + Mosaic.Layout.SCROLL_MARGIN;
   2004     var scrollRight = this.container_.scrollLeft + this.container_.clientWidth;
   2005     if (tileRight > scrollRight)
   2006       targetPosition = tileRight - this.container_.clientWidth;
   2007   }
   2008 
   2009   if (targetPosition) {
   2010     if (opt_animated === false)
   2011       this.container_.scrollLeft = targetPosition;
   2012     else
   2013       this.container_.animatedScrollTo(targetPosition);
   2014   }
   2015 };
   2016 
   2017 /**
   2018  * @return {Rect} Rectangle occupied by the tile's image,
   2019  *   relative to the viewport.
   2020  */
   2021 Mosaic.Tile.prototype.getImageRect = function() {
   2022   if (this.left_ === null)  // Not laid out.
   2023     return null;
   2024 
   2025   var margin = Mosaic.Layout.SPACING / 2;
   2026   return new Rect(this.left_ - this.container_.scrollLeft, this.top_,
   2027       this.width_, this.height_).inflate(-margin, -margin);
   2028 };
   2029 
   2030 /**
   2031  * @return {number} X coordinate of the tile center.
   2032  */
   2033 Mosaic.Tile.prototype.getCenterX = function() {
   2034   return this.left_ + Math.round(this.width_ / 2);
   2035 };
   2036