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