• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 */
15function 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 */
29MosaicMode.prototype.getMosaic = function() { return this.mosaic_; };
30
31/**
32 * @return {string} Mode name.
33 */
34MosaicMode.prototype.getName = function() { return 'mosaic'; };
35
36/**
37 * @return {string} Mode title.
38 */
39MosaicMode.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 */
45MosaicMode.prototype.executeWhenReady = function(action) { action(); };
46
47/**
48 * @return {boolean} Always true (no toolbar fading in this mode).
49 */
50MosaicMode.prototype.hasActiveTool = function() { return true; };
51
52/**
53 * Keydown handler.
54 *
55 * @param {Event} event Event.
56 */
57MosaicMode.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 */
82function 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 */
91Mosaic.prototype.__proto__ = HTMLDivElement.prototype;
92
93/**
94 * Default layout delay in ms.
95 * @const
96 * @type {number}
97 */
98Mosaic.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 */
106Mosaic.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 */
116Mosaic.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 */
131Mosaic.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 */
162Mosaic.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 */
174Mosaic.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 */
197Mosaic.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 */
255Mosaic.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 */
263Mosaic.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 */
272Mosaic.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 */
283Mosaic.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 */
292Mosaic.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 */
304Mosaic.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 */
326Mosaic.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 */
340Mosaic.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 */
352Mosaic.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 */
376Mosaic.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 */
388Mosaic.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 */
402Mosaic.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 */
416Mosaic.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 */
453Mosaic.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 */
478Mosaic.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 */
490Mosaic.prototype.canZoom = function() {
491  return this.tiles_.length < 100;
492};
493
494/**
495 * Shows the mosaic.
496 */
497Mosaic.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 */
519Mosaic.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 */
532Mosaic.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 */
541Mosaic.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 */
613Mosaic.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 */
646Mosaic.SelectionController = function(selectionModel, layoutModel) {
647  cr.ui.ListSelectionController.call(this, selectionModel);
648  this.layoutModel_ = layoutModel;
649};
650
651/**
652 * Extends cr.ui.ListSelectionController.
653 */
654Mosaic.SelectionController.prototype.__proto__ =
655    cr.ui.ListSelectionController.prototype;
656
657/** @override */
658Mosaic.SelectionController.prototype.getLastIndex = function() {
659  return this.layoutModel_.getLaidOutTileCount() - 1;
660};
661
662/** @override */
663Mosaic.SelectionController.prototype.getIndexBefore = function(index) {
664  return this.layoutModel_.getHorizontalAdjacentIndex(index, -1);
665};
666
667/** @override */
668Mosaic.SelectionController.prototype.getIndexAfter = function(index) {
669  return this.layoutModel_.getHorizontalAdjacentIndex(index, 1);
670};
671
672/** @override */
673Mosaic.SelectionController.prototype.getIndexAbove = function(index) {
674  return this.layoutModel_.getVerticalAdjacentIndex(index, -1);
675};
676
677/** @override */
678Mosaic.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 */
691Mosaic.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 */
701Mosaic.Layout.PADDING_TOP = 50;
702
703/**
704 * Blank space at the bottom of the mosaic element.
705 */
706Mosaic.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 */
712Mosaic.Layout.SPACING = 10;
713
714/**
715 * Margin for scrolling using keyboard. Distance between a selected tile
716 * and window border.
717 */
718Mosaic.Layout.SCROLL_MARGIN = 30;
719
720/**
721 * Layout mode: commit to DOM immediately.
722 */
723Mosaic.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 */
729Mosaic.Layout.MODE_TENTATIVE = 'tentative';
730
731/**
732 * Layout mode: never commit layout to DOM.
733 */
734Mosaic.Layout.MODE_DRY_RUN = 'dry_run';
735
736/**
737 * Resets the layout.
738 *
739 * @private
740 */
741Mosaic.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 */
753Mosaic.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 */
762Mosaic.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 */
770Mosaic.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 */
778Mosaic.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 */
786Mosaic.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 */
795Mosaic.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 */
802Mosaic.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 */
813Mosaic.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 */
896Mosaic.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 */
916Mosaic.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 */
951Mosaic.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 */
978Mosaic.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 */
1011Mosaic.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 */
1064Mosaic.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 */
1082Mosaic.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 */
1109Mosaic.Density = function(horizontal, vertical) {
1110  this.horizontal = horizontal;
1111  this.vertical = vertical;
1112};
1113
1114/**
1115 * Minimal horizontal density (tiles per row).
1116 */
1117Mosaic.Density.MIN_HORIZONTAL = 1;
1118
1119/**
1120 * Minimal horizontal density (tiles per row).
1121 */
1122Mosaic.Density.MAX_HORIZONTAL = 3;
1123
1124/**
1125 * Minimal vertical density: force 1 out of 2 rows to containt a single tile.
1126 */
1127Mosaic.Density.MIN_VERTICAL = 2;
1128
1129/**
1130 * Maximal vertical density: force 1 out of 3 rows to containt a single tile.
1131 */
1132Mosaic.Density.MAX_VERTICAL = 3;
1133
1134/**
1135 * @return {Mosaic.Density} Lowest density.
1136 */
1137Mosaic.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 */
1146Mosaic.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 */
1155Mosaic.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 */
1163Mosaic.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 */
1171Mosaic.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 */
1185Mosaic.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 */
1195Mosaic.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 */
1212Mosaic.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 */
1228Mosaic.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 */
1237Mosaic.Column.prototype.getTileCount = function() { return this.tiles_.length };
1238
1239/**
1240 * @return {number} Index of the last tile + 1.
1241 */
1242Mosaic.Column.prototype.getNextTileIndex = function() {
1243  return this.firstTileIndex_ + this.getTileCount();
1244};
1245
1246/**
1247 * @return {number} Global index of the last row + 1.
1248 */
1249Mosaic.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 */
1256Mosaic.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 */
1262Mosaic.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 */
1274Mosaic.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 */
1286Mosaic.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 */
1299Mosaic.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 */
1321Mosaic.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 */
1359Mosaic.Column.prototype.retryWithLowerDensity = function() {
1360  this.density_.decreaseHorizontal();
1361  this.reset_();
1362};
1363
1364/**
1365 * @return {number} Column left edge coordinate.
1366 */
1367Mosaic.Column.prototype.getLeft = function() { return this.left_ };
1368
1369/**
1370 * @return {number} Column right edge coordinate after the layout.
1371 */
1372Mosaic.Column.prototype.getRight = function() {
1373  return this.left_ + this.width_;
1374};
1375
1376/**
1377 * @return {number} Column height after the layout.
1378 */
1379Mosaic.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 */
1386Mosaic.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 */
1405Mosaic.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 */
1442Mosaic.Row = function(firstTileIndex) {
1443  this.firstTileIndex_ = firstTileIndex;
1444  this.tiles_ = [];
1445};
1446
1447/**
1448 * @param {Mosaic.Tile} tile The tile to add.
1449 */
1450Mosaic.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 */
1458Mosaic.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 */
1465Mosaic.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 */
1475Mosaic.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 */
1481Mosaic.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 */
1490Mosaic.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 */
1497Mosaic.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 */
1508Mosaic.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 */
1519Mosaic.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 */
1532Mosaic.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 */
1540Mosaic.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 */
1556Mosaic.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 */
1571Mosaic.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 */
1602Mosaic.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 */
1614Mosaic.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 */
1628Mosaic.Tile.LoadMode = {
1629  LOW_DPI: 0,
1630  HIGH_DPI: 1
1631};
1632
1633/**
1634* Inherit from HTMLDivElement.
1635*/
1636Mosaic.Tile.prototype.__proto__ = HTMLDivElement.prototype;
1637
1638/**
1639 * Minimum tile content size.
1640 */
1641Mosaic.Tile.MIN_CONTENT_SIZE = 64;
1642
1643/**
1644 * Maximum tile content size.
1645 */
1646Mosaic.Tile.MAX_CONTENT_SIZE = 512;
1647
1648/**
1649 * Default size for a tile with no thumbnail image.
1650 */
1651Mosaic.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 */
1657Mosaic.Tile.SMALL_IMAGE_SIZE = 160;
1658
1659/**
1660 * @return {Gallery.Item} The Gallery item.
1661 */
1662Mosaic.Tile.prototype.getItem = function() { return this.item_; };
1663
1664/**
1665 * @return {number} Maximum content height that this tile can have.
1666 */
1667Mosaic.Tile.prototype.getMaxContentHeight = function() {
1668  return this.maxContentHeight_;
1669};
1670
1671/**
1672 * @return {number} The aspect ratio of the tile image.
1673 */
1674Mosaic.Tile.prototype.getAspectRatio = function() { return this.aspectRatio_; };
1675
1676/**
1677 * @return {boolean} True if the tile is initialized.
1678 */
1679Mosaic.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 */
1690Mosaic.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 */
1712Mosaic.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 */
1730Mosaic.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 */
1745Mosaic.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 */
1827Mosaic.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 */
1886Mosaic.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 */
1902Mosaic.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 */
1917Mosaic.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 */
1947Mosaic.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 */
1974Mosaic.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 */
1986Mosaic.Tile.prototype.getCenterX = function() {
1987  return this.left_ + Math.round(this.width_ / 2);
1988};
1989