Fix type annotations of gallery/js/mosaic_mode.js.
[chromium-blink-merge.git] / ui / file_manager / gallery / js / mosaic_mode.js
blob69ace4b54ff42c7149170ca9afa7036e0e8bc9f4
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.
5 /**
6  * @param {!Element} container Content container.
7  * @param {!ErrorBanner} errorBanner Error banner.
8  * @param {!cr.ui.ArrayDataModel} dataModel Data model.
9  * @param {!cr.ui.ListSelectionModel} selectionModel Selection model.
10  * @param {!VolumeManager} volumeManager Volume manager.
11  * @param {function()} toggleMode Function to switch to the Slide mode.
12  * @constructor
13  * @struct
14  */
15 function MosaicMode(
16     container, errorBanner, dataModel, selectionModel, volumeManager,
17     toggleMode) {
18   this.mosaic_ = new Mosaic(assert(container.ownerDocument), errorBanner,
19       dataModel, selectionModel, volumeManager);
20   container.appendChild(this.mosaic_);
22   this.toggleMode_ = toggleMode;
23   this.mosaic_.addEventListener('dblclick', this.toggleMode_);
24   this.showingTimeoutID_ = null;
27 /**
28  * @return {!Mosaic} The mosaic control.
29  */
30 MosaicMode.prototype.getMosaic = function() { return this.mosaic_; };
32 /**
33  * @return {string} Mode name.
34  */
35 MosaicMode.prototype.getName = function() { return 'mosaic'; };
37 /**
38  * @return {string} Mode title.
39  */
40 MosaicMode.prototype.getTitle = function() { return 'GALLERY_MOSAIC'; };
42 /**
43  * Execute an action (this mode has no busy state).
44  * @param {function()} action Action to execute.
45  */
46 MosaicMode.prototype.executeWhenReady = function(action) { action(); };
48 /**
49  * @return {boolean} Always true (no toolbar fading in this mode).
50  */
51 MosaicMode.prototype.hasActiveTool = function() { return true; };
53 /**
54  * Keydown handler.
55  *
56  * @param {!Event} event Event.
57  */
58 MosaicMode.prototype.onKeyDown = function(event) {
59   switch (util.getKeyModifiers(event) + event.keyIdentifier) {
60     case 'Enter':
61       if (!document.activeElement ||
62           document.activeElement.localName !== 'button') {
63         this.toggleMode_();
64         event.preventDefault();
65       }
66       return;
67   }
68   this.mosaic_.onKeyDown(event);
71 ////////////////////////////////////////////////////////////////////////////////
73 /**
74  * Mosaic control.
75  *
76  * @param {!Document} document Document.
77  * @param {!ErrorBanner} errorBanner Error banner.
78  * @param {!cr.ui.ArrayDataModel} dataModel Data model.
79  * @param {!cr.ui.ListSelectionModel} selectionModel Selection model.
80  * @param {!VolumeManager} volumeManager Volume manager.
81  * @return {!Element} Mosaic element.
82  * @constructor
83  * @struct
84  * @extends {HTMLDivElement}
85  * @suppress {checkStructDictInheritance}
86  */
87 function Mosaic(document, errorBanner, dataModel, selectionModel,
88     volumeManager) {
89   // This is a hack to make closure compiler recognize definitions of fields
90   // with this decorate pattern. When this constructor is called as "new
91   // Mosaic(...)", "this" should be Mosaic. In that case, this calls this
92   // constructor again with setting this as HTMLDivElement. When this condition
93   // is false, this method decorates the "this" object, and returns it.
94   if (this instanceof Mosaic) {
95     return Mosaic.call(/** @type {Mosaic} */ (document.createElement('div')),
96         document, errorBanner, dataModel, selectionModel, volumeManager);
97   }
99   this.__proto__ = Mosaic.prototype;
100   this.className = 'mosaic';
102   /**
103    * @type {!cr.ui.ArrayDataModel}
104    * @private
105    */
106   this.dataModel_ = dataModel;
108   /**
109    * @type {!cr.ui.ListSelectionModel}
110    * @private
111    */
112   this.selectionModel_ = selectionModel;
114   /**
115    * @type {!VolumeManager}
116    * @private
117    */
118   this.volumeManager_ = volumeManager;
120   /**
121    * @type {!ErrorBanner}
122    * @private
123    */
124   this.errorBanner_ = errorBanner;
126   /**
127    * @type {Array.<!Mosaic.Tile>}
128    * @private
129    */
130   this.tiles_ = null;
132   /**
133    * @type {boolean}
134    * @private
135    */
136   this.loadVisibleTilesSuppressed_ = false;
138   /**
139    * @type {boolean}
140    * @private
141    */
142   this.loadVisibleTilesScheduled_ = false;
144   /**
145    * @type {number}
146    * @private
147    */
148   this.showingTimeoutID_ = 0;
150   /**
151    * @type {Mosaic.SelectionController}
152    * @private
153    */
154   this.selectionController_ = null;
156   /**
157    * @type {Mosaic.Layout}
158    * @private
159    */
160   this.layoutModel_ = null;
162   /**
163    * @type {boolean}
164    * @private
165    */
166   this.suppressHovering_ = false;
168   /**
169    * @type {number}
170    * @private
171    */
172   this.layoutTimer_ = 0;
174   /**
175    * @type {number}
176    * @private
177    */
178   this.scrollAnimation_ = 0;
180   // Initialization is completed lazily on the first call to |init|.
182   return this;
186  * Inherits from HTMLDivElement.
187  */
188 Mosaic.prototype.__proto__ = HTMLDivElement.prototype;
191  * Default layout delay in ms.
192  * @const
193  * @type {number}
194  */
195 Mosaic.LAYOUT_DELAY = 200;
198  * Smooth scroll animation duration when scrolling using keyboard or
199  * clicking on a partly visible tile. In ms.
200  * @const
201  * @type {number}
202  */
203 Mosaic.ANIMATED_SCROLL_DURATION = 500;
206  * Initializes the mosaic element.
207  */
208 Mosaic.prototype.init = function() {
209   if (this.tiles_)
210     return; // Already initialized, nothing to do.
212   this.layoutModel_ = new Mosaic.Layout();
213   this.onResize_();
215   this.selectionController_ =
216       new Mosaic.SelectionController(this.selectionModel_, this.layoutModel_);
218   this.tiles_ = [];
219   for (var i = 0; i !== this.dataModel_.length; i++) {
220     var locationInfo =
221         this.volumeManager_.getLocationInfo(this.dataModel_.item(i).getEntry());
222     this.tiles_.push(
223         new Mosaic.Tile(
224             this,
225             assertInstanceof(this.dataModel_.item(i), Gallery.Item),
226             locationInfo));
227   }
229   this.selectionModel_.selectedIndexes.forEach(function(index) {
230     this.tiles_[index].select(true);
231   }.bind(this));
233   this.initTiles_(this.tiles_);
235   // The listeners might be called while some tiles are still loading.
236   this.initListeners_();
240  * @return {boolean} Whether mosaic is initialized.
241  */
242 Mosaic.prototype.isInitialized = function() {
243   return !!this.tiles_;
247  * Starts listening to events.
249  * We keep listening to events even when the mosaic is hidden in order to
250  * keep the layout up to date.
252  * @private
253  */
254 Mosaic.prototype.initListeners_ = function() {
255   this.ownerDocument.defaultView.addEventListener(
256       'resize', this.onResize_.bind(this));
258   var mouseEventBound = this.onMouseEvent_.bind(this);
259   this.addEventListener('mousemove', mouseEventBound);
260   this.addEventListener('mousedown', mouseEventBound);
261   this.addEventListener('mouseup', mouseEventBound);
262   this.addEventListener('scroll', this.onScroll_.bind(this));
264   this.selectionModel_.addEventListener('change', this.onSelection_.bind(this));
265   this.selectionModel_.addEventListener('leadIndexChange',
266       this.onLeadChange_.bind(this));
268   this.dataModel_.addEventListener('splice', this.onSplice_.bind(this));
269   this.dataModel_.addEventListener('content', this.onContentChange_.bind(this));
273  * Smoothly scrolls the container to the specified position using
274  * f(x) = sqrt(x) speed function normalized to animation duration.
275  * @param {number} targetPosition Horizontal scroll position in pixels.
276  */
277 Mosaic.prototype.animatedScrollTo = function(targetPosition) {
278   if (this.scrollAnimation_) {
279     webkitCancelAnimationFrame(this.scrollAnimation_);
280     this.scrollAnimation_ = 0;
281   }
283   // Mouse move events are fired without touching the mouse because of scrolling
284   // the container. Therefore, these events have to be suppressed.
285   this.suppressHovering_ = true;
287   // Calculates integral area from t1 to t2 of f(x) = sqrt(x) dx.
288   var integral = function(t1, t2) {
289     return 2.0 / 3.0 * Math.pow(t2, 3.0 / 2.0) -
290            2.0 / 3.0 * Math.pow(t1, 3.0 / 2.0);
291   };
293   var delta = targetPosition - this.scrollLeft;
294   var factor = delta / integral(0, Mosaic.ANIMATED_SCROLL_DURATION);
295   var startTime = Date.now();
296   var lastPosition = 0;
297   var scrollOffset = this.scrollLeft;
299   var animationFrame = function() {
300     var position = Date.now() - startTime;
301     var step = factor *
302         integral(Math.max(0, Mosaic.ANIMATED_SCROLL_DURATION - position),
303                  Math.max(0, Mosaic.ANIMATED_SCROLL_DURATION - lastPosition));
304     scrollOffset += step;
306     var oldScrollLeft = this.scrollLeft;
307     var newScrollLeft = Math.round(scrollOffset);
309     if (oldScrollLeft !== newScrollLeft)
310       this.scrollLeft = newScrollLeft;
312     if (step === 0 || this.scrollLeft !== newScrollLeft) {
313       this.scrollAnimation_ = null;
314       // Release the hovering lock after a safe delay to avoid hovering
315       // a tile because of altering |this.scrollLeft|.
316       setTimeout(function() {
317         if (!this.scrollAnimation_)
318           this.suppressHovering_ = false;
319       }.bind(this), 100);
320     } else {
321       // Continue the animation.
322       this.scrollAnimation_ = requestAnimationFrame(animationFrame);
323     }
325     lastPosition = position;
326   }.bind(this);
328   // Start the animation.
329   this.scrollAnimation_ = requestAnimationFrame(animationFrame);
333  * @return {Mosaic.Tile} Selected tile or undefined if no selection.
334  */
335 Mosaic.prototype.getSelectedTile = function() {
336   return this.tiles_ && this.tiles_[this.selectionModel_.selectedIndex];
340  * @param {number} index Tile index.
341  * @return {ImageRect} Tile's image rectangle.
342  */
343 Mosaic.prototype.getTileRect = function(index) {
344   var tile = this.tiles_[index];
345   return tile && tile.getImageRect();
349  * Scroll the given tile into the viewport.
350  * @param {number} index Tile index.
351  */
352 Mosaic.prototype.scrollIntoViewByIndex = function(index) {
353   var tile = this.tiles_[index];
354   if (tile) tile.scrollIntoView();
358  * Initializes multiple tiles.
360  * @param {!Array.<!Mosaic.Tile>} tiles Array of tiles.
361  * @private
362  */
363 Mosaic.prototype.initTiles_ = function(tiles) {
364   for (var i = 0; i < tiles.length; i++) {
365     tiles[i].init();
366   }
370  * Reloads all tiles.
371  */
372 Mosaic.prototype.reload = function() {
373   this.layoutModel_.reset_();
374   this.tiles_.forEach(function(t) { t.markUnloaded(); });
375   this.initTiles_(this.tiles_);
379  * Layouts the tiles in the order of their indices.
381  * Starts where it last stopped (at #0 the first time).
382  * Stops when all tiles are processed or when the next tile is still loading.
383  */
384 Mosaic.prototype.layout = function() {
385   if (this.layoutTimer_) {
386     clearTimeout(this.layoutTimer_);
387     this.layoutTimer_ = 0;
388   }
389   while (true) {
390     var index = this.layoutModel_.getTileCount();
391     if (index === this.tiles_.length)
392       break; // All tiles done.
393     var tile = this.tiles_[index];
394     if (!tile.isInitialized())
395       break;  // Next layout will try to restart from here.
396     this.layoutModel_.add(tile, index + 1 === this.tiles_.length);
397   }
398   this.loadVisibleTiles_();
402  * Schedules the layout.
404  * @param {number=} opt_delay Delay in ms.
405  */
406 Mosaic.prototype.scheduleLayout = function(opt_delay) {
407   if (!this.layoutTimer_) {
408     this.layoutTimer_ = setTimeout(function() {
409       this.layoutTimer_ = 0;
410       this.layout();
411     }.bind(this), opt_delay || 0);
412   }
416  * Resize handler.
418  * @private
419  */
420 Mosaic.prototype.onResize_ = function() {
421   this.layoutModel_.setViewportSize(this.clientWidth, this.clientHeight -
422       (Mosaic.Layout.PADDING_TOP + Mosaic.Layout.PADDING_BOTTOM));
423   this.scheduleLayout();
427  * Mouse event handler.
429  * @param {!Event} event Event.
430  * @private
431  */
432 Mosaic.prototype.onMouseEvent_ = function(event) {
433   // Navigating with mouse, enable hover state.
434   if (!this.suppressHovering_)
435     this.classList.add('hover-visible');
437   if (event.type === 'mousemove')
438     return;
440   var index = -1;
441   for (var target = event.target;
442        target && (target !== this);
443        target = target.parentNode) {
444     if (target.classList.contains('mosaic-tile')) {
445       index = this.dataModel_.indexOf(target.getItem());
446       break;
447     }
448   }
449   this.selectionController_.handlePointerDownUp(event, index);
453  * Scroll handler.
454  * @private
455  */
456 Mosaic.prototype.onScroll_ = function() {
457   requestAnimationFrame(function() {
458     this.loadVisibleTiles_();
459   }.bind(this));
463  * Selection change handler.
465  * @param {!Event} event Event.
466  * @private
467  */
468 Mosaic.prototype.onSelection_ = function(event) {
469   for (var i = 0; i !== event.changes.length; i++) {
470     var change = event.changes[i];
471     var tile = this.tiles_[change.index];
472     if (tile) tile.select(change.selected);
473   }
477  * Leads item change handler.
479  * @param {!Event} event Event.
480  * @private
481  */
482 Mosaic.prototype.onLeadChange_ = function(event) {
483   var index = event.newValue;
484   if (index >= 0) {
485     var tile = this.tiles_[index];
486     if (tile) tile.scrollIntoView();
487   }
491  * Splice event handler.
493  * @param {!Event} event Event.
494  * @private
495  */
496 Mosaic.prototype.onSplice_ = function(event) {
497   var index = event.index;
498   this.layoutModel_.invalidateFromTile_(index);
500   if (event.removed.length) {
501     for (var t = 0; t !== event.removed.length; t++) {
502       // If the layout for the tile has not done yet, the parent is null.
503       // And the layout will not be done after onSplice_ because it is removed
504       // from this.tiles_.
505       if (this.tiles_[index + t].parentNode)
506         this.removeChild(this.tiles_[index + t]);
507     }
509     this.tiles_.splice(index, event.removed.length);
511     // No items left, show the banner.
512     if (this.getItemCount_() === 0)
513       this.errorBanner_.show('GALLERY_NO_IMAGES');
515     this.scheduleLayout(Mosaic.LAYOUT_DELAY);
516   }
518   if (event.added.length) {
519     var newTiles = [];
520     for (var t = 0; t !== event.added.length; t++)
521       newTiles.push(new Mosaic.Tile(this,
522             assertInstanceof(this.dataModel_.item(index + t), Gallery.Item)));
524     this.tiles_.splice.apply(this.tiles_, [index, 0].concat(newTiles));
525     this.initTiles_(newTiles);
526     this.scheduleLayout(Mosaic.LAYOUT_DELAY);
527   }
529   if (this.tiles_.length !== this.dataModel_.length)
530     console.error('Mosaic is out of sync');
534  * Content change handler.
536  * @param {!Event} event Event.
537  * @private
538  */
539 Mosaic.prototype.onContentChange_ = function(event) {
540   if (!this.tiles_)
541     return;
543   if (!event.metadata)
544     return; // Thumbnail unchanged, nothing to do.
546   var index = this.dataModel_.indexOf(event.item);
547   if (index !== this.selectionModel_.selectedIndex)
548     console.error('Content changed for unselected item');
550   this.layoutModel_.invalidateFromTile_(index);
551   this.tiles_[index].init();
552   this.tiles_[index].unload();
553   this.tiles_[index].load(
554       Mosaic.Tile.LoadMode.HIGH_DPI,
555       this.scheduleLayout.bind(this, Mosaic.LAYOUT_DELAY));
559  * Keydown event handler.
561  * @param {!Event} event Event.
562  * @return {boolean} True if the event has been consumed.
563  */
564 Mosaic.prototype.onKeyDown = function(event) {
565   this.selectionController_.handleKeyDown(event);
566   if (event.defaultPrevented)  // Navigating with keyboard, hide hover state.
567     this.classList.remove('hover-visible');
568   return event.defaultPrevented;
572  * @return {boolean} True if the mosaic zoom effect can be applied. It is
573  * too slow if there are to many images.
574  * TODO(kaznacheev): Consider unloading the images that are out of the viewport.
575  */
576 Mosaic.prototype.canZoom = function() {
577   return this.tiles_.length < 100;
581  * Shows the mosaic.
582  */
583 Mosaic.prototype.show = function() {
584   // If the items are empty, just show the error message.
585   if (this.getItemCount_() === 0)
586     this.errorBanner_.show('GALLERY_NO_IMAGES');
588   var duration = ImageView.MODE_TRANSITION_DURATION;
589   if (this.canZoom()) {
590     // Fade in in parallel with the zoom effect.
591     this.setAttribute('visible', 'zooming');
592   } else {
593     // Mosaic is not animating but the large image is. Fade in the mosaic
594     // shortly before the large image animation is done.
595     duration -= 100;
596   }
597   this.showingTimeoutID_ = setTimeout(function() {
598     this.showingTimeoutID_ = 0;
599     // Make the selection visible.
600     // If the mosaic is not animated it will start fading in now.
601     this.setAttribute('visible', 'normal');
602     this.loadVisibleTiles_();
603   }.bind(this), duration);
607  * Hides the mosaic.
608  */
609 Mosaic.prototype.hide = function() {
610   this.errorBanner_.clear();
612   if (this.showingTimeoutID_ !== 0) {
613     clearTimeout(this.showingTimeoutID_);
614     this.showingTimeoutID_ = 0;
615   }
616   this.removeAttribute('visible');
620  * Checks if the mosaic view is visible.
621  * @return {boolean} True if visible, false otherwise.
622  * @private
623  */
624 Mosaic.prototype.isVisible_ = function() {
625   return this.hasAttribute('visible');
629  * Loads visible tiles. Ignores consecutive calls. Does not reload already
630  * loaded images.
631  * @private
632  */
633 Mosaic.prototype.loadVisibleTiles_ = function() {
634   if (this.loadVisibleTilesSuppressed_) {
635     this.loadVisibleTilesScheduled_ = true;
636     return;
637   }
639   this.loadVisibleTilesSuppressed_ = true;
640   this.loadVisibleTilesScheduled_ = false;
641   setTimeout(function() {
642     this.loadVisibleTilesSuppressed_ = false;
643     if (this.loadVisibleTilesScheduled_)
644       this.loadVisibleTiles_();
645   }.bind(this), 100);
647   // Tiles only in the viewport (visible).
648   var visibleRect = new ImageRect(
649       0, 0, this.clientWidth, this.clientHeight);
651   // Tiles in the viewport and also some distance on the left and right.
652   var renderableRect = new ImageRect(
653       -this.clientWidth,
654       0,
655       3 * this.clientWidth,
656       this.clientHeight);
658   // Unload tiles out of scope.
659   for (var index = 0; index < this.tiles_.length; index++) {
660     var tile = this.tiles_[index];
661     var imageRect = tile.getImageRect();
662     // Unload a thumbnail.
663     if (imageRect && !imageRect.intersects(renderableRect))
664       tile.unload();
665   }
667   // Load the visible tiles first.
668   var allVisibleLoaded = true;
669   // Show high-dpi only when the mosaic view is visible.
670   var loadMode = this.isVisible_() ? Mosaic.Tile.LoadMode.HIGH_DPI :
671       Mosaic.Tile.LoadMode.LOW_DPI;
672   for (var index = 0; index < this.tiles_.length; index++) {
673     var tile = this.tiles_[index];
674     var imageRect = tile.getImageRect();
675     // Load a thumbnail.
676     if (!tile.isLoading(loadMode) && !tile.isLoaded(loadMode) && imageRect &&
677         imageRect.intersects(visibleRect)) {
678       tile.load(loadMode, function() {});
679       allVisibleLoaded = false;
680     }
681   }
683   // Load also another, nearby, if the visible has been already loaded.
684   if (allVisibleLoaded) {
685     for (var index = 0; index < this.tiles_.length; index++) {
686       var tile = this.tiles_[index];
687       var imageRect = tile.getImageRect();
688       // Load a thumbnail.
689       if (!tile.isLoading() && !tile.isLoaded() && imageRect &&
690           imageRect.intersects(renderableRect)) {
691         tile.load(Mosaic.Tile.LoadMode.LOW_DPI, function() {});
692       }
693     }
694   }
698  * Applies reset the zoom transform.
700  * @param {ImageRect} tileRect Tile rectangle. Reset the transform if null.
701  * @param {ImageRect} imageRect Large image rectangle. Reset the transform if
702  *     null.
703  * @param {boolean=} opt_instant True of the transition should be instant.
704  */
705 Mosaic.prototype.transform = function(tileRect, imageRect, opt_instant) {
706   if (opt_instant) {
707     this.style.webkitTransitionDuration = '0';
708   } else {
709     this.style.webkitTransitionDuration =
710         ImageView.MODE_TRANSITION_DURATION + 'ms';
711   }
713   if (this.canZoom() && tileRect && imageRect) {
714     var scaleX = imageRect.width / tileRect.width;
715     var scaleY = imageRect.height / tileRect.height;
716     var shiftX = (imageRect.left + imageRect.width / 2) -
717         (tileRect.left + tileRect.width / 2);
718     var shiftY = (imageRect.top + imageRect.height / 2) -
719         (tileRect.top + tileRect.height / 2);
720     this.style.webkitTransform =
721         'translate(' + shiftX * scaleX + 'px, ' + shiftY * scaleY + 'px)' +
722         'scaleX(' + scaleX + ') scaleY(' + scaleY + ')';
723   } else {
724     this.style.webkitTransform = '';
725   }
729  * @return {number} Item count
730  * @private
731  */
732 Mosaic.prototype.getItemCount_ = function() {
733   return this.dataModel_.length;
736 ////////////////////////////////////////////////////////////////////////////////
739  * Creates a selection controller that is to be used with grid.
740  * @param {!cr.ui.ListSelectionModel} selectionModel The selection model to
741  *     interact with.
742  * @param {!Mosaic.Layout} layoutModel The layout model to use.
743  * @constructor
744  * @struct
745  * @extends {cr.ui.ListSelectionController}
746  * @suppress {checkStructDictInheritance}
747  */
748 Mosaic.SelectionController = function(selectionModel, layoutModel) {
749   cr.ui.ListSelectionController.call(this, selectionModel);
750   this.layoutModel_ = layoutModel;
754  * Extends cr.ui.ListSelectionController.
755  */
756 Mosaic.SelectionController.prototype.__proto__ =
757     cr.ui.ListSelectionController.prototype;
759 /** @override */
760 Mosaic.SelectionController.prototype.getLastIndex = function() {
761   return this.layoutModel_.getLaidOutTileCount() - 1;
764 /** @override */
765 Mosaic.SelectionController.prototype.getIndexBefore = function(index) {
766   return this.layoutModel_.getHorizontalAdjacentIndex(index, -1);
769 /** @override */
770 Mosaic.SelectionController.prototype.getIndexAfter = function(index) {
771   return this.layoutModel_.getHorizontalAdjacentIndex(index, 1);
774 /** @override */
775 Mosaic.SelectionController.prototype.getIndexAbove = function(index) {
776   return this.layoutModel_.getVerticalAdjacentIndex(index, -1);
779 /** @override */
780 Mosaic.SelectionController.prototype.getIndexBelow = function(index) {
781   return this.layoutModel_.getVerticalAdjacentIndex(index, 1);
784 ////////////////////////////////////////////////////////////////////////////////
787  * Mosaic layout.
789  * @param {string=} opt_mode Layout mode.
790  * @param {Mosaic.Density=} opt_maxDensity Layout density.
791  * @constructor
792  * @struct
793  */
794 Mosaic.Layout = function(opt_mode, opt_maxDensity) {
795   this.mode_ = opt_mode || Mosaic.Layout.Mode.TENTATIVE;
796   this.maxDensity_ = opt_maxDensity || Mosaic.Density.createHighest();
798   /**
799    * @type {!Array.<!Mosaic.Column>}
800    * @private
801    */
802   this.columns_ = [];
804   /**
805    * @type {Mosaic.Column}
806    * @private
807    */
808   this.newColumn_ = null;
810   /**
811    * @type {number}
812    * @private
813    */
814   this.viewportWidth_ = 0;
816   /**
817    * @type {number}
818    * @private
819    */
820   this.viewportHeight_ = 0;
822   /**
823    * @type {Mosaic.Density}
824    * @private
825    */
826   this.density_ = null;
828   this.reset_();
832  * Blank space at the top of the mosaic element. We do not do that in CSS
833  * to make transition effects easier.
834  * @type {number}
835  * @const
836  */
837 Mosaic.Layout.PADDING_TOP = 50;
840  * Blank space at the bottom of the mosaic element.
841  * @type {number}
842  * @const
843  */
844 Mosaic.Layout.PADDING_BOTTOM = 50;
847  * Horizontal and vertical spacing between images. Should be kept in sync
848  * with the style of .mosaic-item in gallery.css (= 2 * ( 4 + 1))
849  * @type {number}
850  * @const
851  */
852 Mosaic.Layout.SPACING = 10;
855  * Margin for scrolling using keyboard. Distance between a selected tile
856  * and window border.
857  * @type {number}
858  * @const
859  */
860 Mosaic.Layout.SCROLL_MARGIN = 30;
863  * Layout mode.
864  * @enum {string}
865  */
866 Mosaic.Layout.Mode = {
867   // Commit to DOM immediately.
868   FINAL: 'final',
869   // Do not commit layout to DOM until it is complete or the viewport
870   // overflows.
871   TENTATIVE: 'tentative',
872   // Never commit layout to DOM.
873   DRY_RUN: 'dry_run',
877  * Resets the layout.
879  * @private
880  */
881 Mosaic.Layout.prototype.reset_ = function() {
882   this.columns_ = [];
883   this.newColumn_ = null;
884   this.density_ = Mosaic.Density.createLowest();
885   if (this.mode_ !== Mosaic.Layout.Mode.DRY_RUN)  // DRY_RUN is sticky.
886     this.mode_ = Mosaic.Layout.Mode.TENTATIVE;
890  * @param {number} width Viewport width.
891  * @param {number} height Viewport height.
892  */
893 Mosaic.Layout.prototype.setViewportSize = function(width, height) {
894   this.viewportWidth_ = width;
895   this.viewportHeight_ = height;
896   this.reset_();
900  * @return {number} Total width of the layout.
901  */
902 Mosaic.Layout.prototype.getWidth = function() {
903   var lastColumn = this.getLastColumn_();
904   return lastColumn ? lastColumn.getRight() : 0;
908  * @return {number} Total height of the layout.
909  */
910 Mosaic.Layout.prototype.getHeight = function() {
911   var firstColumn = this.columns_[0];
912   return firstColumn ? firstColumn.getHeight() : 0;
916  * @return {!Array.<!Mosaic.Tile>} All tiles in the layout.
917  */
918 Mosaic.Layout.prototype.getTiles = function() {
919   return Array.prototype.concat.apply([],
920       this.columns_.map(function(c) { return c.getTiles(); }));
924  * @return {number} Total number of tiles added to the layout.
925  */
926 Mosaic.Layout.prototype.getTileCount = function() {
927   return this.getLaidOutTileCount() +
928       (this.newColumn_ ? this.newColumn_.getTileCount() : 0);
932  * @return {Mosaic.Column} The last column or null for empty layout.
933  * @private
934  */
935 Mosaic.Layout.prototype.getLastColumn_ = function() {
936   return this.columns_.length ? this.columns_[this.columns_.length - 1] : null;
940  * @return {number} Total number of tiles in completed columns.
941  */
942 Mosaic.Layout.prototype.getLaidOutTileCount = function() {
943   var lastColumn = this.getLastColumn_();
944   return lastColumn ? lastColumn.getNextTileIndex() : 0;
948  * Adds a tile to the layout.
950  * @param {!Mosaic.Tile} tile The tile to be added.
951  * @param {boolean} isLast True if this tile is the last.
952  */
953 Mosaic.Layout.prototype.add = function(tile, isLast) {
954   var layoutQueue = [tile];
956   // There are two levels of backtracking in the layout algorithm.
957   // |Mosaic.Layout.density_| tracks the state of the 'global' backtracking
958   // which aims to use as much of the viewport space as possible.
959   // It starts with the lowest density and increases it until the layout
960   // fits into the viewport. If it does not fit even at the highest density,
961   // the layout continues with the highest density.
962   //
963   // |Mosaic.Column.density_| tracks the state of the 'local' backtracking
964   // which aims to avoid producing unnaturally looking columns.
965   // It starts with the current global density and decreases it until the column
966   // looks nice.
968   while (layoutQueue.length) {
969     if (!this.newColumn_) {
970       var lastColumn = this.getLastColumn_();
971       this.newColumn_ = new Mosaic.Column(
972           this.columns_.length,
973           lastColumn ? lastColumn.getNextRowIndex() : 0,
974           lastColumn ? lastColumn.getNextTileIndex() : 0,
975           lastColumn ? lastColumn.getRight() : 0,
976           this.viewportHeight_,
977           this.density_.clone());
978     }
980     this.newColumn_.add(layoutQueue.shift());
982     var isFinalColumn = isLast && !layoutQueue.length;
984     if (!this.newColumn_.prepareLayout(isFinalColumn))
985       continue; // Column is incomplete.
987     if (this.newColumn_.isSuboptimal()) {
988       layoutQueue = this.newColumn_.getTiles().concat(layoutQueue);
989       this.newColumn_.retryWithLowerDensity();
990       continue;
991     }
993     this.columns_.push(this.newColumn_);
994     this.newColumn_ = null;
996     if (this.mode_ === Mosaic.Layout.Mode.FINAL && isFinalColumn) {
997       this.commit_();
998       continue;
999     }
1001     if (this.getWidth() > this.viewportWidth_) {
1002       // Viewport completely filled.
1003       if (this.density_.equals(this.maxDensity_)) {
1004         // Max density reached, commit if tentative, just continue if dry run.
1005         if (this.mode_ === Mosaic.Layout.Mode.TENTATIVE)
1006           this.commit_();
1007         continue;
1008       }
1010       // Rollback the entire layout, retry with higher density.
1011       layoutQueue = this.getTiles().concat(layoutQueue);
1012       this.columns_ = [];
1013       this.density_.increase();
1014       continue;
1015     }
1017     if (isFinalColumn && this.mode_ === Mosaic.Layout.Mode.TENTATIVE) {
1018       // The complete tentative layout fits into the viewport.
1019       var stretched = this.findHorizontalLayout_();
1020       if (stretched)
1021         this.columns_ = stretched.columns_;
1022       // Center the layout in the viewport and commit.
1023       this.commit_((this.viewportWidth_ - this.getWidth()) / 2,
1024                    (this.viewportHeight_ - this.getHeight()) / 2);
1025     }
1026   }
1030  * Commits the tentative layout.
1032  * @param {number=} opt_offsetX Horizontal offset.
1033  * @param {number=} opt_offsetY Vertical offset.
1034  * @private
1035  */
1036 Mosaic.Layout.prototype.commit_ = function(opt_offsetX, opt_offsetY) {
1037   for (var i = 0; i !== this.columns_.length; i++) {
1038     this.columns_[i].layout(opt_offsetX, opt_offsetY);
1039   }
1040   this.mode_ = Mosaic.Layout.Mode.FINAL;
1044  * Finds the most horizontally stretched layout built from the same tiles.
1046  * The main layout algorithm fills the entire available viewport height.
1047  * If there is too few tiles this results in a layout that is unnaturally
1048  * stretched in the vertical direction.
1050  * This method tries a number of smaller heights and returns the most
1051  * horizontally stretched layout that still fits into the viewport.
1053  * @return {Mosaic.Layout} A horizontally stretched layout.
1054  * @private
1055  */
1056 Mosaic.Layout.prototype.findHorizontalLayout_ = function() {
1057   // If the layout aspect ratio is not dramatically different from
1058   // the viewport aspect ratio then there is no need to optimize.
1059   if (this.getWidth() / this.getHeight() >
1060       this.viewportWidth_ / this.viewportHeight_ * 0.9)
1061     return null;
1063   var tiles = this.getTiles();
1064   if (tiles.length === 1)
1065     return null;  // Single tile layout is always the same.
1067   var tileHeights = tiles.map(function(t) { return t.getMaxContentHeight(); });
1068   var minTileHeight = Math.min.apply(null, tileHeights);
1070   for (var h = minTileHeight; h < this.viewportHeight_; h += minTileHeight) {
1071     var layout = new Mosaic.Layout(
1072         Mosaic.Layout.Mode.DRY_RUN, this.density_.clone());
1073     layout.setViewportSize(this.viewportWidth_, h);
1074     for (var t = 0; t !== tiles.length; t++)
1075       layout.add(tiles[t], t + 1 === tiles.length);
1077     if (layout.getWidth() <= this.viewportWidth_)
1078       return layout;
1079   }
1081   return null;
1085  * Invalidates the layout after the given tile was modified (added, deleted or
1086  * changed dimensions).
1088  * @param {number} index Tile index.
1089  * @private
1090  */
1091 Mosaic.Layout.prototype.invalidateFromTile_ = function(index) {
1092   var columnIndex = this.getColumnIndexByTile_(index);
1093   if (columnIndex < 0)
1094     return; // Index not in the layout, probably already invalidated.
1096   if (this.columns_[columnIndex].getLeft() >= this.viewportWidth_) {
1097     // The columns to the right cover the entire viewport width, so there is no
1098     // chance that the modified layout would fit into the viewport.
1099     // No point in restarting the entire layout, keep the columns to the right.
1100     console.assert(this.mode_ === Mosaic.Layout.Mode.FINAL,
1101         'Expected FINAL layout mode');
1102     this.columns_ = this.columns_.slice(0, columnIndex);
1103     this.newColumn_ = null;
1104   } else {
1105     // There is a chance that the modified layout would fit into the viewport.
1106     this.reset_();
1107     this.mode_ = Mosaic.Layout.Mode.TENTATIVE;
1108   }
1112  * Gets the index of the tile to the left or to the right from the given tile.
1114  * @param {number} index Tile index.
1115  * @param {number} direction -1 for left, 1 for right.
1116  * @return {number} Adjacent tile index.
1117  */
1118 Mosaic.Layout.prototype.getHorizontalAdjacentIndex = function(
1119     index, direction) {
1120   var column = this.getColumnIndexByTile_(index);
1121   if (column < 0) {
1122     console.error('Cannot find column for tile #' + index);
1123     return -1;
1124   }
1126   var row = this.columns_[column].getRowByTileIndex(index);
1127   if (!row) {
1128     console.error('Cannot find row for tile #' + index);
1129     return -1;
1130   }
1132   var sameRowNeighbourIndex = index + direction;
1133   if (row.hasTile(sameRowNeighbourIndex))
1134     return sameRowNeighbourIndex;
1136   var adjacentColumn = column + direction;
1137   if (adjacentColumn < 0 || adjacentColumn === this.columns_.length)
1138     return -1;
1140   return this.columns_[adjacentColumn].
1141       getEdgeTileIndex_(row.getCenterY(), -direction);
1145  * Gets the index of the tile to the top or to the bottom from the given tile.
1147  * @param {number} index Tile index.
1148  * @param {number} direction -1 for above, 1 for below.
1149  * @return {number} Adjacent tile index.
1150  */
1151 Mosaic.Layout.prototype.getVerticalAdjacentIndex = function(
1152     index, direction) {
1153   var column = this.getColumnIndexByTile_(index);
1154   if (column < 0) {
1155     console.error('Cannot find column for tile #' + index);
1156     return -1;
1157   }
1159   var row = this.columns_[column].getRowByTileIndex(index);
1160   if (!row) {
1161     console.error('Cannot find row for tile #' + index);
1162     return -1;
1163   }
1165   // Find the first item in the next row, or the last item in the previous row.
1166   var adjacentRowNeighbourIndex =
1167       row.getEdgeTileIndex_(direction) + direction;
1169   if (adjacentRowNeighbourIndex < 0 ||
1170       adjacentRowNeighbourIndex > this.getTileCount() - 1)
1171     return -1;
1173   if (!this.columns_[column].hasTile(adjacentRowNeighbourIndex)) {
1174     // It is not in the current column, so return it.
1175     return adjacentRowNeighbourIndex;
1176   } else {
1177     // It is in the current column, so we have to find optically the closest
1178     // tile in the adjacent row.
1179     var adjacentRow = this.columns_[column].getRowByTileIndex(
1180         adjacentRowNeighbourIndex);
1181     var previousTileCenterX = row.getTileByIndex(index).getCenterX();
1183     // Find the closest one.
1184     var closestIndex = -1;
1185     var closestDistance;
1186     var adjacentRowTiles = adjacentRow.getTiles();
1187     for (var t = 0; t !== adjacentRowTiles.length; t++) {
1188       var distance =
1189           Math.abs(adjacentRowTiles[t].getCenterX() - previousTileCenterX);
1190       if (closestIndex === -1 || distance < closestDistance) {
1191         closestIndex = adjacentRow.getEdgeTileIndex_(-1) + t;
1192         closestDistance = distance;
1193       }
1194     }
1195     return closestIndex;
1196   }
1200  * @param {number} index Tile index.
1201  * @return {number} Index of the column containing the given tile.
1202  * @private
1203  */
1204 Mosaic.Layout.prototype.getColumnIndexByTile_ = function(index) {
1205   for (var c = 0; c !== this.columns_.length; c++) {
1206     if (this.columns_[c].hasTile(index))
1207       return c;
1208   }
1209   return -1;
1213  * Scales the given array of size values to satisfy 3 conditions:
1214  * 1. The new sizes must be integer.
1215  * 2. The new sizes must sum up to the given |total| value.
1216  * 3. The relative proportions of the sizes should be as close to the original
1217  *    as possible.
1219  * @param {!Array.<number>} sizes Array of sizes.
1220  * @param {number} newTotal New total size.
1221  */
1222 Mosaic.Layout.rescaleSizesToNewTotal = function(sizes, newTotal) {
1223   var total = 0;
1225   var partialTotals = [0];
1226   for (var i = 0; i !== sizes.length; i++) {
1227     total += sizes[i];
1228     partialTotals.push(total);
1229   }
1231   var scale = newTotal / total;
1233   for (i = 0; i !== sizes.length; i++) {
1234     sizes[i] = Math.round(partialTotals[i + 1] * scale) -
1235         Math.round(partialTotals[i] * scale);
1236   }
1239 ////////////////////////////////////////////////////////////////////////////////
1242  * Representation of the layout density.
1244  * @param {number} horizontal Horizontal density, number tiles per row.
1245  * @param {number} vertical Vertical density, frequency of rows forced to
1246  *   contain a single tile.
1247  * @constructor
1248  * @struct
1249  */
1250 Mosaic.Density = function(horizontal, vertical) {
1251   this.horizontal = horizontal;
1252   this.vertical = vertical;
1256  * Minimal horizontal density (tiles per row).
1257  * @type {number}
1258  * @const
1259  */
1260 Mosaic.Density.MIN_HORIZONTAL = 1;
1263  * Minimal horizontal density (tiles per row).
1264  * @type {number}
1265  * @const
1266  */
1267 Mosaic.Density.MAX_HORIZONTAL = 3;
1270  * Minimal vertical density: force 1 out of 2 rows to containt a single tile.
1271  * @type {number}
1272  * @const
1273  */
1274 Mosaic.Density.MIN_VERTICAL = 2;
1277  * Maximal vertical density: force 1 out of 3 rows to containt a single tile.
1278  * @type {number}
1279  * @const
1280  */
1281 Mosaic.Density.MAX_VERTICAL = 3;
1284  * @return {!Mosaic.Density} Lowest density.
1285  */
1286 Mosaic.Density.createLowest = function() {
1287   return new Mosaic.Density(
1288       Mosaic.Density.MIN_HORIZONTAL,
1289       Mosaic.Density.MIN_VERTICAL /* ignored when horizontal is at min */);
1293  * @return {!Mosaic.Density} Highest density.
1294  */
1295 Mosaic.Density.createHighest = function() {
1296   return new Mosaic.Density(
1297       Mosaic.Density.MAX_HORIZONTAL,
1298       Mosaic.Density.MAX_VERTICAL);
1302  * @return {!Mosaic.Density} A clone of this density object.
1303  */
1304 Mosaic.Density.prototype.clone = function() {
1305   return new Mosaic.Density(this.horizontal, this.vertical);
1309  * @param {!Mosaic.Density} that The other object.
1310  * @return {boolean} True if equal.
1311  */
1312 Mosaic.Density.prototype.equals = function(that) {
1313   return this.horizontal === that.horizontal &&
1314          this.vertical === that.vertical;
1318  * Increases the density to the next level.
1319  */
1320 Mosaic.Density.prototype.increase = function() {
1321   if (this.horizontal === Mosaic.Density.MIN_HORIZONTAL ||
1322       this.vertical === Mosaic.Density.MAX_VERTICAL) {
1323     console.assert(this.horizontal < Mosaic.Density.MAX_HORIZONTAL);
1324     this.horizontal++;
1325     this.vertical = Mosaic.Density.MIN_VERTICAL;
1326   } else {
1327     this.vertical++;
1328   }
1332  * Decreases horizontal density.
1333  */
1334 Mosaic.Density.prototype.decreaseHorizontal = function() {
1335   console.assert(this.horizontal > Mosaic.Density.MIN_HORIZONTAL);
1336   this.horizontal--;
1340  * @param {number} tileCount Number of tiles in the row.
1341  * @param {number} rowIndex Global row index.
1342  * @return {boolean} True if the row is complete.
1343  */
1344 Mosaic.Density.prototype.isRowComplete = function(tileCount, rowIndex) {
1345   return (tileCount === this.horizontal) || (rowIndex % this.vertical) === 0;
1348 ////////////////////////////////////////////////////////////////////////////////
1351  * A column in a mosaic layout. Contains rows.
1353  * @param {number} index Column index.
1354  * @param {number} firstRowIndex Global row index.
1355  * @param {number} firstTileIndex Index of the first tile in the column.
1356  * @param {number} left Left edge coordinate.
1357  * @param {number} maxHeight Maximum height.
1358  * @param {!Mosaic.Density} density Layout density.
1359  * @constructor
1360  * @struct
1361  */
1362 Mosaic.Column = function(index, firstRowIndex, firstTileIndex, left, maxHeight,
1363                          density) {
1364   this.index_ = index;
1365   this.firstRowIndex_ = firstRowIndex;
1366   this.firstTileIndex_ = firstTileIndex;
1367   this.left_ = left;
1368   this.maxHeight_ = maxHeight;
1369   this.density_ = density;
1371   /**
1372    * @type {number}
1373    * @private
1374    */
1375   this.width_ = 0;
1377   /**
1378    * @type {!Array.<!Mosaic.Tile>}
1379    * @private
1380    */
1381   this.tiles_ = [];
1383   /**
1384    * @type {!Array.<!Mosaic.Row>}
1385    * @private
1386    */
1387   this.rows_ = [];
1389   /**
1390    * @type {Mosaic.Row}
1391    * @private
1392    */
1393   this.newRow_ = null;
1395   /**
1396    * @type {!Array.<number>}
1397    * @private
1398    */
1399   this.rowHeights_ = [];
1401   /**
1402    * @type {number}
1403    * @private
1404    */
1405   this.height_ = 0;
1407   this.reset_();
1411  * Resets the layout.
1412  * @private
1413  */
1414 Mosaic.Column.prototype.reset_ = function() {
1415   this.tiles_ = [];
1416   this.rows_ = [];
1417   this.newRow_ = null;
1421  * @return {number} Number of tiles in the column.
1422  */
1423 Mosaic.Column.prototype.getTileCount = function() { return this.tiles_.length };
1426  * @return {number} Index of the last tile + 1.
1427  */
1428 Mosaic.Column.prototype.getNextTileIndex = function() {
1429   return this.firstTileIndex_ + this.getTileCount();
1433  * @return {number} Global index of the last row + 1.
1434  */
1435 Mosaic.Column.prototype.getNextRowIndex = function() {
1436   return this.firstRowIndex_ + this.rows_.length;
1440  * @return {!Array.<!Mosaic.Tile>} Array of tiles in the column.
1441  */
1442 Mosaic.Column.prototype.getTiles = function() { return this.tiles_ };
1445  * @param {number} index Tile index.
1446  * @return {boolean} True if this column contains the tile with the given index.
1447  */
1448 Mosaic.Column.prototype.hasTile = function(index) {
1449   return this.firstTileIndex_ <= index &&
1450       index < (this.firstTileIndex_ + this.getTileCount());
1454  * @param {number} y Y coordinate.
1455  * @param {number} direction -1 for left, 1 for right.
1456  * @return {number} Index of the tile lying on the edge of the column at the
1457  *    given y coordinate.
1458  * @private
1459  */
1460 Mosaic.Column.prototype.getEdgeTileIndex_ = function(y, direction) {
1461   for (var r = 0; r < this.rows_.length; r++) {
1462     if (this.rows_[r].coversY(y))
1463       return this.rows_[r].getEdgeTileIndex_(direction);
1464   }
1465   return -1;
1469  * @param {number} index Tile index.
1470  * @return {Mosaic.Row} The row containing the tile with a given index.
1471  */
1472 Mosaic.Column.prototype.getRowByTileIndex = function(index) {
1473   for (var r = 0; r !== this.rows_.length; r++) {
1474     if (this.rows_[r].hasTile(index))
1475       return this.rows_[r];
1476   }
1477   return null;
1481  * Adds a tile to the column.
1483  * @param {!Mosaic.Tile} tile The tile to add.
1484  */
1485 Mosaic.Column.prototype.add = function(tile) {
1486   var rowIndex = this.getNextRowIndex();
1488   if (!this.newRow_)
1489     this.newRow_ = new Mosaic.Row(this.getNextTileIndex());
1491   this.tiles_.push(tile);
1492   this.newRow_.add(tile);
1494   if (this.density_.isRowComplete(this.newRow_.getTileCount(), rowIndex)) {
1495     this.rows_.push(this.newRow_);
1496     this.newRow_ = null;
1497   }
1501  * Prepares the column layout.
1503  * @param {boolean=} opt_force True if the layout must be performed even for an
1504  *   incomplete column.
1505  * @return {boolean} True if the layout was performed.
1506  */
1507 Mosaic.Column.prototype.prepareLayout = function(opt_force) {
1508   if (opt_force && this.newRow_) {
1509     this.rows_.push(this.newRow_);
1510     this.newRow_ = null;
1511   }
1513   if (this.rows_.length === 0)
1514     return false;
1516   this.width_ = Math.min.apply(
1517       null, this.rows_.map(function(row) { return row.getMaxWidth() }));
1519   this.height_ = 0;
1521   this.rowHeights_ = [];
1522   for (var r = 0; r !== this.rows_.length; r++) {
1523     var rowHeight = this.rows_[r].getHeightForWidth(this.width_);
1524     this.height_ += rowHeight;
1525     this.rowHeights_.push(rowHeight);
1526   }
1528   var overflow = this.height_ / this.maxHeight_;
1529   if (!opt_force && (overflow < 1))
1530     return false;
1532   if (overflow > 1) {
1533     // Scale down the column width and height.
1534     this.width_ = Math.round(this.width_ / overflow);
1535     this.height_ = this.maxHeight_;
1536     Mosaic.Layout.rescaleSizesToNewTotal(this.rowHeights_, this.maxHeight_);
1537   }
1539   return true;
1543  * Retries the column layout with less tiles per row.
1544  */
1545 Mosaic.Column.prototype.retryWithLowerDensity = function() {
1546   this.density_.decreaseHorizontal();
1547   this.reset_();
1551  * @return {number} Column left edge coordinate.
1552  */
1553 Mosaic.Column.prototype.getLeft = function() { return this.left_ };
1556  * @return {number} Column right edge coordinate after the layout.
1557  */
1558 Mosaic.Column.prototype.getRight = function() {
1559   return this.left_ + this.width_;
1563  * @return {number} Column height after the layout.
1564  */
1565 Mosaic.Column.prototype.getHeight = function() { return this.height_ };
1568  * Performs the column layout.
1569  * @param {number=} opt_offsetX Horizontal offset.
1570  * @param {number=} opt_offsetY Vertical offset.
1571  */
1572 Mosaic.Column.prototype.layout = function(opt_offsetX, opt_offsetY) {
1573   opt_offsetX = opt_offsetX || 0;
1574   opt_offsetY = opt_offsetY || 0;
1575   var rowTop = Mosaic.Layout.PADDING_TOP;
1576   for (var r = 0; r !== this.rows_.length; r++) {
1577     this.rows_[r].layout(
1578         opt_offsetX + this.left_,
1579         opt_offsetY + rowTop,
1580         this.width_,
1581         this.rowHeights_[r]);
1582     rowTop += this.rowHeights_[r];
1583   }
1587  * Checks if the column layout is too ugly to be displayed.
1589  * @return {boolean} True if the layout is suboptimal.
1590  */
1591 Mosaic.Column.prototype.isSuboptimal = function() {
1592   var tileCounts =
1593       this.rows_.map(function(row) { return row.getTileCount() });
1595   var maxTileCount = Math.max.apply(null, tileCounts);
1596   if (maxTileCount === 1)
1597     return false;  // Every row has exactly 1 tile, as optimal as it gets.
1599   var sizes =
1600       this.tiles_.map(function(tile) { return tile.getMaxContentHeight() });
1602   // Ugly layout #1: all images are small and some are one the same row.
1603   var allSmall = Math.max.apply(null, sizes) <= Mosaic.Tile.SMALL_IMAGE_SIZE;
1604   if (allSmall)
1605     return true;
1607   // Ugly layout #2: all images are large and none occupies an entire row.
1608   var allLarge = Math.min.apply(null, sizes) > Mosaic.Tile.SMALL_IMAGE_SIZE;
1609   var allCombined = Math.min.apply(null, tileCounts) !== 1;
1610   if (allLarge && allCombined)
1611     return true;
1613   // Ugly layout #3: some rows have too many tiles for the resulting width.
1614   if (this.width_ / maxTileCount < 100)
1615     return true;
1617   return false;
1620 ////////////////////////////////////////////////////////////////////////////////
1623  * A row in a mosaic layout. Contains tiles.
1625  * @param {number} firstTileIndex Index of the first tile in the row.
1626  * @constructor
1627  * @struct
1628  */
1629 Mosaic.Row = function(firstTileIndex) {
1630   this.firstTileIndex_ = firstTileIndex;
1631   this.tiles_ = [];
1633   /**
1634    * @type {number}
1635    * @private
1636    */
1637   this.top_ = 0;
1639   /**
1640    * @type {number}
1641    * @private
1642    */
1643   this.height_ = 0;
1647  * @param {!Mosaic.Tile} tile The tile to add.
1648  */
1649 Mosaic.Row.prototype.add = function(tile) {
1650   console.assert(this.getTileCount() < Mosaic.Density.MAX_HORIZONTAL);
1651   this.tiles_.push(tile);
1655  * @return {!Array.<!Mosaic.Tile>} Array of tiles in the row.
1656  */
1657 Mosaic.Row.prototype.getTiles = function() { return this.tiles_ };
1660  * Gets a tile by index.
1661  * @param {number} index Tile index.
1662  * @return {Mosaic.Tile} Requested tile or null if not found.
1663  */
1664 Mosaic.Row.prototype.getTileByIndex = function(index) {
1665   if (!this.hasTile(index))
1666     return null;
1667   return this.tiles_[index - this.firstTileIndex_];
1672  * @return {number} Number of tiles in the row.
1673  */
1674 Mosaic.Row.prototype.getTileCount = function() { return this.tiles_.length };
1677  * @param {number} index Tile index.
1678  * @return {boolean} True if this row contains the tile with the given index.
1679  */
1680 Mosaic.Row.prototype.hasTile = function(index) {
1681   return this.firstTileIndex_ <= index &&
1682       index < (this.firstTileIndex_ + this.tiles_.length);
1686  * @param {number} y Y coordinate.
1687  * @return {boolean} True if this row covers the given Y coordinate.
1688  */
1689 Mosaic.Row.prototype.coversY = function(y) {
1690   return this.top_ <= y && y < (this.top_ + this.height_);
1694  * @return {number} Y coordinate of the tile center.
1695  */
1696 Mosaic.Row.prototype.getCenterY = function() {
1697   return this.top_ + Math.round(this.height_ / 2);
1701  * Gets the first or the last tile.
1703  * @param {number} direction -1 for the first tile, 1 for the last tile.
1704  * @return {number} Tile index.
1705  * @private
1706  */
1707 Mosaic.Row.prototype.getEdgeTileIndex_ = function(direction) {
1708   if (direction < 0)
1709     return this.firstTileIndex_;
1710   else
1711     return this.firstTileIndex_ + this.getTileCount() - 1;
1715  * @return {number} Aspect ration of the combined content box of this row.
1716  * @private
1717  */
1718 Mosaic.Row.prototype.getTotalContentAspectRatio_ = function() {
1719   var sum = 0;
1720   for (var t = 0; t !== this.tiles_.length; t++)
1721     sum += this.tiles_[t].getAspectRatio();
1722   return sum;
1726  * @return {number} Total horizontal spacing in this row. This includes
1727  *   the spacing between the tiles and both left and right margins.
1729  * @private
1730  */
1731 Mosaic.Row.prototype.getTotalHorizontalSpacing_ = function() {
1732   return Mosaic.Layout.SPACING * this.getTileCount();
1736  * @return {number} Maximum width that this row may have without overscaling
1737  * any of the tiles.
1738  */
1739 Mosaic.Row.prototype.getMaxWidth = function() {
1740   var contentHeight = Math.min.apply(null,
1741       this.tiles_.map(function(tile) { return tile.getMaxContentHeight() }));
1743   var contentWidth =
1744       Math.round(contentHeight * this.getTotalContentAspectRatio_());
1745   return contentWidth + this.getTotalHorizontalSpacing_();
1749  * Computes the height that best fits the supplied row width given
1750  * aspect ratios of the tiles in this row.
1752  * @param {number} width Row width.
1753  * @return {number} Height.
1754  */
1755 Mosaic.Row.prototype.getHeightForWidth = function(width) {
1756   var contentWidth = width - this.getTotalHorizontalSpacing_();
1757   var contentHeight =
1758       Math.round(contentWidth / this.getTotalContentAspectRatio_());
1759   return contentHeight + Mosaic.Layout.SPACING;
1763  * Positions the row in the mosaic.
1765  * @param {number} left Left position.
1766  * @param {number} top Top position.
1767  * @param {number} width Width.
1768  * @param {number} height Height.
1769  */
1770 Mosaic.Row.prototype.layout = function(left, top, width, height) {
1771   this.top_ = top;
1772   this.height_ = height;
1774   var contentWidth = width - this.getTotalHorizontalSpacing_();
1775   var contentHeight = height - Mosaic.Layout.SPACING;
1777   var tileContentWidth = this.tiles_.map(
1778       function(tile) { return tile.getAspectRatio() });
1780   Mosaic.Layout.rescaleSizesToNewTotal(tileContentWidth, contentWidth);
1782   var tileLeft = left;
1783   for (var t = 0; t !== this.tiles_.length; t++) {
1784     var tileWidth = tileContentWidth[t] + Mosaic.Layout.SPACING;
1785     this.tiles_[t].layout(tileLeft, top, tileWidth, height);
1786     tileLeft += tileWidth;
1787   }
1790 ////////////////////////////////////////////////////////////////////////////////
1793  * A single tile of the image mosaic.
1795  * @param {!Element} container Container element.
1796  * @param {!Gallery.Item} item Gallery item associated with this tile.
1797  * @param {EntryLocation=} opt_locationInfo Location information for the tile.
1798  * @return {!Element} The new tile element.
1799  * @constructor
1800  * @extends {HTMLDivElement}
1801  * @struct
1802  * @suppress {checkStructDictInheritance}
1803  */
1804 Mosaic.Tile = function(container, item, opt_locationInfo) {
1805   // This is a hack to make closure compiler recognize definitions of fields
1806   // with this decorate pattern. When this constructor is called as "new
1807   // Mosaic.Tile(...)", "this" should be Mosaic.Tile. In that case, this calls
1808   // this constructor again with setting this as HTMLDivElement. When this
1809   // condition is false, this method decorates the "this" object, and returns
1810   // it.
1811   if (this instanceof Mosaic.Tile) {
1812     return Mosaic.Tile.call(
1813         /** @type {Mosaic.Tile} */ (document.createElement('div')),
1814         container, item, opt_locationInfo);
1815   }
1817   this.__proto__ = Mosaic.Tile.prototype;
1818   this.className = 'mosaic-tile';
1820   /**
1821    * @type {!Element}
1822    * @private
1823    */
1824   this.container_ = container;
1826   /**
1827    * @type {!Gallery.Item}
1828    * @private
1829    */
1830   this.item_ = item;
1832   /**
1833    * @type {boolean}
1834    * @private
1835    */
1836   this.hidpiEmbedded_ = !!opt_locationInfo && opt_locationInfo.isDriveBased;
1838   /**
1839    * @type {?number}
1840    * @private
1841    */
1842   this.left_ = null; // Mark as not laid out.
1844   /**
1845    * @type {number}
1846    * @private
1847    */
1848   this.top_ = 0;
1850   /**
1851    * @type {number}
1852    * @private
1853    */
1854   this.width_ = 0;
1856   /**
1857    * @type {number}
1858    * @private
1859    */
1860   this.height_ = 0;
1862   /**
1863    * @type {number}
1864    * @private
1865    */
1866   this.maxContentHeight_ = 0;
1868   /**
1869    * @type {number}
1870    * @private
1871    */
1872   this.aspectRatio_ = 0;
1874   /**
1875    * @type {ThumbnailLoader}
1876    * @private
1877    */
1878   this.thumbnailPreloader_ = null;
1880   /**
1881    * @type {ThumbnailLoader}
1882    * @private
1883    */
1884   this.thumbnailLoader_ = null;
1886   /**
1887    * @type {boolean}
1888    * @private
1889    */
1890   this.imagePreloaded_ = false;
1892   /**
1893    * @type {boolean}
1894    * @private
1895    */
1896   this.imageLoaded_ = false;
1898   /**
1899    * @type {boolean}
1900    * @private
1901    */
1902   this.imagePreloading_ = false;
1904   /**
1905    * @type {boolean}
1906    * @private
1907    */
1908   this.imageLoading_ = false;
1910   /**
1911    * @type {HTMLDivElement}
1912    * @private
1913    */
1914   this.wrapper_ = null;
1916   return this;
1920  * Load mode for the tile's image.
1921  * @enum {number}
1922  */
1923 Mosaic.Tile.LoadMode = {
1924   LOW_DPI: 0,
1925   HIGH_DPI: 1
1929 * Inherit from HTMLDivElement.
1931 Mosaic.Tile.prototype.__proto__ = HTMLDivElement.prototype;
1934  * Minimum tile content size.
1935  * @type {number}
1936  * @const
1937  */
1938 Mosaic.Tile.MIN_CONTENT_SIZE = 64;
1941  * Maximum tile content size.
1942  * @type {number}
1943  * @const
1944  */
1945 Mosaic.Tile.MAX_CONTENT_SIZE = 512;
1948  * Default size for a tile with no thumbnail image.
1949  * @type {number}
1950  * @const
1951  */
1952 Mosaic.Tile.GENERIC_ICON_SIZE = 128;
1955  * Max size of an image considered to be 'small'.
1956  * Small images are laid out slightly differently.
1957  * @type {number}
1958  * @const
1959  */
1960 Mosaic.Tile.SMALL_IMAGE_SIZE = 160;
1963  * @return {!Gallery.Item} The Gallery item.
1964  */
1965 Mosaic.Tile.prototype.getItem = function() { return this.item_; };
1968  * @return {number} Maximum content height that this tile can have.
1969  */
1970 Mosaic.Tile.prototype.getMaxContentHeight = function() {
1971   return this.maxContentHeight_;
1975  * @return {number} The aspect ratio of the tile image.
1976  */
1977 Mosaic.Tile.prototype.getAspectRatio = function() { return this.aspectRatio_; };
1980  * @return {boolean} True if the tile is initialized.
1981  */
1982 Mosaic.Tile.prototype.isInitialized = function() {
1983   return !!this.maxContentHeight_;
1987  * Checks whether the image of specified (or better resolution) has been loaded.
1989  * @param {Mosaic.Tile.LoadMode=} opt_loadMode Loading mode, default: LOW_DPI.
1990  * @return {boolean} True if the tile is loaded with the specified dpi or
1991  *     better.
1992  */
1993 Mosaic.Tile.prototype.isLoaded = function(opt_loadMode) {
1994   var loadMode = opt_loadMode || Mosaic.Tile.LoadMode.LOW_DPI;
1995   switch (loadMode) {
1996     case Mosaic.Tile.LoadMode.LOW_DPI:
1997       if (this.imagePreloaded_ || this.imageLoaded_)
1998         return true;
1999       break;
2000     case Mosaic.Tile.LoadMode.HIGH_DPI:
2001       if (this.imageLoaded_)
2002         return true;
2003       break;
2004   }
2005   return false;
2009  * Checks whether the image of specified (or better resolution) is being loaded.
2011  * @param {Mosaic.Tile.LoadMode=} opt_loadMode Loading mode, default: LOW_DPI.
2012  * @return {boolean} True if the tile is being loaded with the specified dpi or
2013  *     better.
2014  */
2015 Mosaic.Tile.prototype.isLoading = function(opt_loadMode) {
2016   var loadMode = opt_loadMode || Mosaic.Tile.LoadMode.LOW_DPI;
2017   switch (loadMode) {
2018     case Mosaic.Tile.LoadMode.LOW_DPI:
2019       if (this.imagePreloading_ || this.imageLoading_)
2020         return true;
2021       break;
2022     case Mosaic.Tile.LoadMode.HIGH_DPI:
2023       if (this.imageLoading_)
2024         return true;
2025       break;
2026   }
2027   return false;
2031  * Marks the tile as not loaded to prevent it from participating in the layout.
2032  */
2033 Mosaic.Tile.prototype.markUnloaded = function() {
2034   this.maxContentHeight_ = 0;
2035   if (this.thumbnailLoader_) {
2036     this.thumbnailLoader_.cancel();
2037     this.imagePreloaded_ = false;
2038     this.imagePreloading_ = false;
2039     this.imageLoaded_ = false;
2040     this.imageLoading_ = false;
2041   }
2045  * Initializes the thumbnail in the tile. Does not load an image, but sets
2046  * target dimensions using metadata.
2047  */
2048 Mosaic.Tile.prototype.init = function() {
2049   var metadata = this.getItem().getMetadata();
2050   this.markUnloaded();
2051   this.left_ = null;  // Mark as not laid out.
2053   // Set higher priority for the selected elements to load them first.
2054   var priority = this.getAttribute('selected') ? 2 : 3;
2056   // Use embedded thumbnails on Drive, since they have higher resolution.
2057   this.thumbnailLoader_ = new ThumbnailLoader(
2058       this.getItem().getEntry(),
2059       ThumbnailLoader.LoaderType.CANVAS,
2060       metadata,
2061       undefined,  // Media type.
2062       this.hidpiEmbedded_ ?
2063           ThumbnailLoader.UseEmbedded.USE_EMBEDDED :
2064           ThumbnailLoader.UseEmbedded.NO_EMBEDDED,
2065       priority);
2067   // If no hidpi embedded thumbnail available, then use the low resolution
2068   // for preloading.
2069   if (!this.hidpiEmbedded_) {
2070     this.thumbnailPreloader_ = new ThumbnailLoader(
2071         this.getItem().getEntry(),
2072         ThumbnailLoader.LoaderType.CANVAS,
2073         metadata,
2074         undefined,  // Media type.
2075         ThumbnailLoader.UseEmbedded.USE_EMBEDDED,
2076         // Preloaders have always higher priotity, so the preload images
2077         // are loaded as soon as possible.
2078         2);
2079   }
2081   // Dimensions are always acquired from the metadata. For local files, it is
2082   // extracted from headers. For Drive files, it is received via the Drive API.
2083   // If the dimensions are not available, then the fallback dimensions will be
2084   // used (same as for the generic icon).
2085   var width;
2086   var height;
2087   if (metadata.media && metadata.media.width) {
2088     width = metadata.media.width;
2089     height = metadata.media.height;
2090   } else if (metadata.external && metadata.external.imageWidth &&
2091              metadata.external.imageHeight) {
2092     width = metadata.external.imageWidth;
2093     height = metadata.external.imageHeight;
2094   } else {
2095     // No dimensions in metadata, then use the generic dimensions.
2096     width = Mosaic.Tile.GENERIC_ICON_SIZE;
2097     height = Mosaic.Tile.GENERIC_ICON_SIZE;
2098   }
2100   if (width > height) {
2101     if (width > Mosaic.Tile.MAX_CONTENT_SIZE) {
2102       height = Math.round(height * Mosaic.Tile.MAX_CONTENT_SIZE / width);
2103       width = Mosaic.Tile.MAX_CONTENT_SIZE;
2104     }
2105   } else {
2106     if (height > Mosaic.Tile.MAX_CONTENT_SIZE) {
2107       width = Math.round(width * Mosaic.Tile.MAX_CONTENT_SIZE / height);
2108       height = Mosaic.Tile.MAX_CONTENT_SIZE;
2109     }
2110   }
2111   this.maxContentHeight_ = Math.max(Mosaic.Tile.MIN_CONTENT_SIZE, height);
2112   this.aspectRatio_ = width / height;
2116  * Loads an image into the tile.
2118  * The mode argument is a hint. Use low-dpi for faster response, and high-dpi
2119  * for better output, but possibly affecting performance.
2121  * If the mode is high-dpi, then a the high-dpi image is loaded, but also
2122  * low-dpi image is loaded for preloading (if available).
2123  * For the low-dpi mode, only low-dpi image is loaded. If not available, then
2124  * the high-dpi image is loaded as a fallback.
2126  * @param {!Mosaic.Tile.LoadMode} loadMode Loading mode.
2127  * @param {function(boolean)} onImageLoaded Callback when image is loaded.
2128  *     The argument is true for success, false for failure.
2129  */
2130 Mosaic.Tile.prototype.load = function(loadMode, onImageLoaded) {
2131   // Attaches the image to the tile and finalizes loading process for the
2132   // specified loader.
2133   var finalizeLoader = function(mode, success, loader) {
2134     if (success && this.wrapper_) {
2135       // Show the fade-in animation only when previously there was no image
2136       // attached in this tile.
2137       if (!this.imageLoaded_ && !this.imagePreloaded_)
2138         this.wrapper_.classList.add('animated');
2139       else
2140         this.wrapper_.classList.remove('animated');
2141     }
2142     loader.attachImage(this.wrapper_, ThumbnailLoader.FillMode.OVER_FILL);
2143     onImageLoaded(success);
2144     switch (mode) {
2145       case Mosaic.Tile.LoadMode.LOW_DPI:
2146         this.imagePreloading_ = false;
2147         this.imagePreloaded_ = true;
2148         break;
2149       case Mosaic.Tile.LoadMode.HIGH_DPI:
2150         this.imageLoading_ = false;
2151         this.imageLoaded_ = true;
2152         break;
2153     }
2154   }.bind(this);
2156   // Always load the low-dpi image first if it is available for the fastest
2157   // feedback.
2158   if (!this.imagePreloading_ && this.thumbnailPreloader_) {
2159     this.imagePreloading_ = true;
2160     this.thumbnailPreloader_.loadDetachedImage(function(success) {
2161       // Hi-dpi loaded first, ignore this call then.
2162       if (this.imageLoaded_)
2163         return;
2164       finalizeLoader(Mosaic.Tile.LoadMode.LOW_DPI,
2165                      success,
2166                      this.thumbnailPreloader_);
2167     }.bind(this));
2168   }
2170   // Load the high-dpi image only when it is requested, or the low-dpi is not
2171   // available.
2172   if (!this.imageLoading_ &&
2173       (loadMode === Mosaic.Tile.LoadMode.HIGH_DPI || !this.imagePreloading_)) {
2174     this.imageLoading_ = true;
2175     this.thumbnailLoader_.loadDetachedImage(function(success) {
2176       // Cancel preloading, since the hi-dpi image is ready.
2177       if (this.thumbnailPreloader_)
2178         this.thumbnailPreloader_.cancel();
2179       finalizeLoader(Mosaic.Tile.LoadMode.HIGH_DPI,
2180                      success,
2181                      this.thumbnailLoader_);
2182     }.bind(this));
2183   }
2187  * Unloads an image from the tile.
2188  */
2189 Mosaic.Tile.prototype.unload = function() {
2190   this.thumbnailLoader_.cancel();
2191   if (this.thumbnailPreloader_)
2192     this.thumbnailPreloader_.cancel();
2193   this.imagePreloaded_ = false;
2194   this.imageLoaded_ = false;
2195   this.imagePreloading_ = false;
2196   this.imageLoading_ = false;
2197   this.wrapper_.innerText = '';
2201  * Selects/unselects the tile.
2203  * @param {boolean} on True if selected.
2204  */
2205 Mosaic.Tile.prototype.select = function(on) {
2206   if (on)
2207     this.setAttribute('selected', true);
2208   else
2209     this.removeAttribute('selected');
2213  * Positions the tile in the mosaic.
2215  * @param {number} left Left position.
2216  * @param {number} top Top position.
2217  * @param {number} width Width.
2218  * @param {number} height Height.
2219  */
2220 Mosaic.Tile.prototype.layout = function(left, top, width, height) {
2221   this.left_ = left;
2222   this.top_ = top;
2223   this.width_ = width;
2224   this.height_ = height;
2226   this.style.left = left + 'px';
2227   this.style.top = top + 'px';
2228   this.style.width = width + 'px';
2229   this.style.height = height + 'px';
2231   if (!this.wrapper_) {  // First time, create DOM.
2232     this.container_.appendChild(this);
2233     var border = util.createChild(this, 'img-border');
2234     this.wrapper_ = assertInstanceof(util.createChild(border, 'img-wrapper'),
2235         HTMLDivElement);
2236   }
2237   if (this.hasAttribute('selected'))
2238     this.scrollIntoView(false);
2240   if (this.imageLoaded_) {
2241     this.thumbnailLoader_.attachImage(this.wrapper_,
2242                                       ThumbnailLoader.FillMode.OVER_FILL);
2243   }
2247  * If the tile is not fully visible scroll the parent to make it fully visible.
2248  * @param {boolean=} opt_animated True, if scroll should be animated,
2249  *     default: true.
2250  */
2251 Mosaic.Tile.prototype.scrollIntoView = function(opt_animated) {
2252   if (this.left_ === null)  // Not laid out.
2253     return;
2255   var targetPosition;
2256   var tileLeft = this.left_ - Mosaic.Layout.SCROLL_MARGIN;
2257   if (tileLeft < this.container_.scrollLeft) {
2258     targetPosition = tileLeft;
2259   } else {
2260     var tileRight = this.left_ + this.width_ + Mosaic.Layout.SCROLL_MARGIN;
2261     var scrollRight = this.container_.scrollLeft + this.container_.clientWidth;
2262     if (tileRight > scrollRight)
2263       targetPosition = tileRight - this.container_.clientWidth;
2264   }
2266   if (targetPosition) {
2267     if (opt_animated === false)
2268       this.container_.scrollLeft = targetPosition;
2269     else
2270       this.container_.animatedScrollTo(targetPosition);
2271   }
2275  * @return {ImageRect} Rectangle occupied by the tile's image,
2276  *   relative to the viewport.
2277  */
2278 Mosaic.Tile.prototype.getImageRect = function() {
2279   if (this.left_ === null)  // Not laid out.
2280     return null;
2282   var margin = Mosaic.Layout.SPACING / 2;
2283   return new ImageRect(this.left_ - this.container_.scrollLeft, this.top_,
2284       this.width_, this.height_).inflate(-margin, -margin);
2288  * @return {number} X coordinate of the tile center.
2289  */
2290 Mosaic.Tile.prototype.getCenterX = function() {
2291   return this.left_ + Math.round(this.width_ / 2);