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