Supervised user whitelists: Cleanup
[chromium-blink-merge.git] / ui / file_manager / gallery / js / mosaic_mode.js
blob1138a67418aba5269da9f6f52c956b138763c775
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 {!VolumeManagerWrapper} volumeManager Volume manager.
11  * @param {function(Event=)} 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 /**
72  * Enters the debug mode.
73  */
74 MosaicMode.prototype.debugMe = function() {
75   this.mosaic_.debugMe();
78 ////////////////////////////////////////////////////////////////////////////////
80 /**
81  * Mosaic control.
82  *
83  * @param {!Document} document Document.
84  * @param {!ErrorBanner} errorBanner Error banner.
85  * @param {!cr.ui.ArrayDataModel} dataModel Data model.
86  * @param {!cr.ui.ListSelectionModel} selectionModel Selection model.
87  * @param {!VolumeManagerWrapper} volumeManager Volume manager.
88  * @return {!Element} Mosaic element.
89  * @constructor
90  * @struct
91  * @extends {HTMLDivElement}
92  * @suppress {checkStructDictInheritance}
93  */
94 function Mosaic(document, errorBanner, dataModel, selectionModel,
95     volumeManager) {
96   // This is a hack to make closure compiler recognize definitions of fields
97   // with this decorate pattern. When this constructor is called as "new
98   // Mosaic(...)", "this" should be Mosaic. In that case, this calls this
99   // constructor again with setting this as HTMLDivElement. When this condition
100   // is false, this method decorates the "this" object, and returns it.
101   if (this instanceof Mosaic) {
102     return Mosaic.call(/** @type {Mosaic} */ (document.createElement('div')),
103         document, errorBanner, dataModel, selectionModel, volumeManager);
104   }
106   this.__proto__ = Mosaic.prototype;
107   this.className = 'mosaic';
109   /**
110    * @type {!cr.ui.ArrayDataModel}
111    * @private
112    */
113   this.dataModel_ = dataModel;
115   /**
116    * @type {!cr.ui.ListSelectionModel}
117    * @private
118    */
119   this.selectionModel_ = selectionModel;
121   /**
122    * @type {!VolumeManagerWrapper}
123    * @private
124    */
125   this.volumeManager_ = volumeManager;
127   /**
128    * @type {!ErrorBanner}
129    * @private
130    */
131   this.errorBanner_ = errorBanner;
133   /**
134    * @type {Array.<!Mosaic.Tile>}
135    * @private
136    */
137   this.tiles_ = null;
139   /**
140    * @type {boolean}
141    * @private
142    */
143   this.loadVisibleTilesSuppressed_ = false;
145   /**
146    * @type {boolean}
147    * @private
148    */
149   this.loadVisibleTilesScheduled_ = false;
151   /**
152    * @type {number}
153    * @private
154    */
155   this.showingTimeoutID_ = 0;
157   /**
158    * @type {Mosaic.SelectionController}
159    * @private
160    */
161   this.selectionController_ = null;
163   /**
164    * @type {Mosaic.Layout}
165    * @private
166    */
167   this.layoutModel_ = null;
169   /**
170    * @type {boolean}
171    * @private
172    */
173   this.suppressHovering_ = false;
175   /**
176    * @type {number}
177    * @private
178    */
179   this.layoutTimer_ = 0;
181   /**
182    * @type {number}
183    * @private
184    */
185   this.scrollAnimation_ = 0;
187   // Initialization is completed lazily on the first call to |init|.
189   return this;
193  * Inherits from HTMLDivElement.
194  */
195 Mosaic.prototype.__proto__ = HTMLDivElement.prototype;
198  * Default layout delay in ms.
199  * @const
200  * @type {number}
201  */
202 Mosaic.LAYOUT_DELAY = 200;
205  * Smooth scroll animation duration when scrolling using keyboard or
206  * clicking on a partly visible tile. In ms.
207  * @const
208  * @type {number}
209  */
210 Mosaic.ANIMATED_SCROLL_DURATION = 500;
213  * Initializes the mosaic element.
214  */
215 Mosaic.prototype.init = function() {
216   if (this.tiles_)
217     return; // Already initialized, nothing to do.
219   this.layoutModel_ = new Mosaic.Layout();
220   this.onResize_();
222   this.selectionController_ =
223       new Mosaic.SelectionController(this.selectionModel_, this.layoutModel_);
225   this.tiles_ = [];
226   for (var i = 0; i !== this.dataModel_.length; i++) {
227     var locationInfo =
228         this.volumeManager_.getLocationInfo(this.dataModel_.item(i).getEntry());
229     this.tiles_.push(
230         new Mosaic.Tile(
231             this,
232             assertInstanceof(this.dataModel_.item(i), Gallery.Item),
233             locationInfo));
234   }
236   this.selectionModel_.selectedIndexes.forEach(function(index) {
237     this.tiles_[index].select(true);
238   }.bind(this));
240   this.initTiles_(this.tiles_);
242   // The listeners might be called while some tiles are still loading.
243   this.initListeners_();
247  * @return {boolean} Whether mosaic is initialized.
248  */
249 Mosaic.prototype.isInitialized = function() {
250   return !!this.tiles_;
254  * Starts listening to events.
256  * We keep listening to events even when the mosaic is hidden in order to
257  * keep the layout up to date.
259  * @private
260  */
261 Mosaic.prototype.initListeners_ = function() {
262   this.ownerDocument.defaultView.addEventListener(
263       'resize', this.onResize_.bind(this));
265   var mouseEventBound = this.onMouseEvent_.bind(this);
266   this.addEventListener('mousemove', mouseEventBound);
267   this.addEventListener('mousedown', mouseEventBound);
268   this.addEventListener('mouseup', mouseEventBound);
269   this.addEventListener('scroll', this.onScroll_.bind(this));
271   this.selectionModel_.addEventListener('change', this.onSelection_.bind(this));
272   this.selectionModel_.addEventListener('leadIndexChange',
273       this.onLeadChange_.bind(this));
275   this.dataModel_.addEventListener('splice', this.onSplice_.bind(this));
276   this.dataModel_.addEventListener('content', this.onContentChange_.bind(this));
280  * Smoothly scrolls the container to the specified position using
281  * f(x) = sqrt(x) speed function normalized to animation duration.
282  * @param {number} targetPosition Horizontal scroll position in pixels.
283  */
284 Mosaic.prototype.animatedScrollTo = function(targetPosition) {
285   if (this.scrollAnimation_) {
286     webkitCancelAnimationFrame(this.scrollAnimation_);
287     this.scrollAnimation_ = 0;
288   }
290   // Mouse move events are fired without touching the mouse because of scrolling
291   // the container. Therefore, these events have to be suppressed.
292   this.suppressHovering_ = true;
294   // Calculates integral area from t1 to t2 of f(x) = sqrt(x) dx.
295   var integral = function(t1, t2) {
296     return 2.0 / 3.0 * Math.pow(t2, 3.0 / 2.0) -
297            2.0 / 3.0 * Math.pow(t1, 3.0 / 2.0);
298   };
300   var delta = targetPosition - this.scrollLeft;
301   var factor = delta / integral(0, Mosaic.ANIMATED_SCROLL_DURATION);
302   var startTime = Date.now();
303   var lastPosition = 0;
304   var scrollOffset = this.scrollLeft;
306   var animationFrame = function() {
307     var position = Date.now() - startTime;
308     var step = factor *
309         integral(Math.max(0, Mosaic.ANIMATED_SCROLL_DURATION - position),
310                  Math.max(0, Mosaic.ANIMATED_SCROLL_DURATION - lastPosition));
311     scrollOffset += step;
313     var oldScrollLeft = this.scrollLeft;
314     var newScrollLeft = Math.round(scrollOffset);
316     if (oldScrollLeft !== newScrollLeft)
317       this.scrollLeft = newScrollLeft;
319     if (step === 0 || this.scrollLeft !== newScrollLeft) {
320       this.scrollAnimation_ = 0;
321       // Release the hovering lock after a safe delay to avoid hovering
322       // a tile because of altering |this.scrollLeft|.
323       setTimeout(function() {
324         if (!this.scrollAnimation_)
325           this.suppressHovering_ = false;
326       }.bind(this), 100);
327     } else {
328       // Continue the animation.
329       this.scrollAnimation_ = requestAnimationFrame(animationFrame);
330     }
332     lastPosition = position;
333   }.bind(this);
335   // Start the animation.
336   this.scrollAnimation_ = requestAnimationFrame(animationFrame);
340  * @return {Mosaic.Tile} Selected tile or undefined if no selection.
341  */
342 Mosaic.prototype.getSelectedTile = function() {
343   return this.tiles_ && this.tiles_[this.selectionModel_.selectedIndex];
347  * @param {number} index Tile index.
348  * @return {ImageRect} Tile's image rectangle.
349  */
350 Mosaic.prototype.getTileRect = function(index) {
351   var tile = this.tiles_[index];
352   return tile && tile.getImageRect();
356  * Scroll the given tile into the viewport.
357  * @param {number} index Tile index.
358  */
359 Mosaic.prototype.scrollIntoViewByIndex = function(index) {
360   var tile = this.tiles_[index];
361   if (tile) tile.scrollIntoView();
365  * Initializes multiple tiles.
367  * @param {!Array.<!Mosaic.Tile>} tiles Array of tiles.
368  * @private
369  */
370 Mosaic.prototype.initTiles_ = function(tiles) {
371   for (var i = 0; i < tiles.length; i++) {
372     tiles[i].init();
373   }
377  * Reloads all tiles.
378  */
379 Mosaic.prototype.reload = function() {
380   this.layoutModel_.reset_();
381   this.tiles_.forEach(function(t) { t.markUnloaded(); });
382   this.initTiles_(this.tiles_);
386  * Layouts the tiles in the order of their indices.
388  * Starts where it last stopped (at #0 the first time).
389  * Stops when all tiles are processed or when the next tile is still loading.
390  */
391 Mosaic.prototype.layout = function() {
392   if (this.layoutTimer_) {
393     clearTimeout(this.layoutTimer_);
394     this.layoutTimer_ = 0;
395   }
396   while (true) {
397     var index = this.layoutModel_.getTileCount();
398     if (index === this.tiles_.length)
399       break; // All tiles done.
400     var tile = this.tiles_[index];
401     if (!tile.isInitialized())
402       break;  // Next layout will try to restart from here.
403     this.layoutModel_.add(tile, index + 1 === this.tiles_.length);
404   }
405   this.loadVisibleTiles_();
409  * Schedules the layout.
411  * @param {number=} opt_delay Delay in ms.
412  */
413 Mosaic.prototype.scheduleLayout = function(opt_delay) {
414   if (!this.layoutTimer_) {
415     this.layoutTimer_ = setTimeout(function() {
416       this.layoutTimer_ = 0;
417       this.layout();
418     }.bind(this), opt_delay || 0);
419   }
423  * Resize handler.
425  * @private
426  */
427 Mosaic.prototype.onResize_ = function() {
428   this.layoutModel_.setViewportSize(this.clientWidth, this.clientHeight -
429       (Mosaic.Layout.PADDING_TOP + Mosaic.Layout.PADDING_BOTTOM));
430   this.scheduleLayout();
434  * Mouse event handler.
436  * @param {!Event} event Event.
437  * @private
438  */
439 Mosaic.prototype.onMouseEvent_ = function(event) {
440   // Navigating with mouse, enable hover state.
441   if (!this.suppressHovering_)
442     this.classList.add('hover-visible');
444   if (event.type === 'mousemove')
445     return;
447   var index = -1;
448   for (var target = event.target;
449        target && (target !== this);
450        target = target.parentNode) {
451     if (target.classList.contains('mosaic-tile')) {
452       index = this.dataModel_.indexOf(target.getItem());
453       break;
454     }
455   }
456   this.selectionController_.handlePointerDownUp(event, index);
460  * Scroll handler.
461  * @private
462  */
463 Mosaic.prototype.onScroll_ = function() {
464   requestAnimationFrame(function() {
465     this.loadVisibleTiles_();
466   }.bind(this));
470  * Selection change handler.
472  * @param {!Event} event Event.
473  * @private
474  */
475 Mosaic.prototype.onSelection_ = function(event) {
476   for (var i = 0; i !== event.changes.length; i++) {
477     var change = event.changes[i];
478     var tile = this.tiles_[change.index];
479     if (tile) tile.select(change.selected);
480   }
484  * Leads item change handler.
486  * @param {!Event} event Event.
487  * @private
488  */
489 Mosaic.prototype.onLeadChange_ = function(event) {
490   var index = event.newValue;
491   if (index >= 0) {
492     var tile = this.tiles_[index];
493     if (tile) tile.scrollIntoView();
494   }
498  * Splice event handler.
500  * @param {!Event} event Event.
501  * @private
502  */
503 Mosaic.prototype.onSplice_ = function(event) {
504   var index = event.index;
505   this.layoutModel_.invalidateFromTile_(index);
507   if (event.removed.length) {
508     for (var t = 0; t !== event.removed.length; t++) {
509       // If the layout for the tile has not done yet, the parent is null.
510       // And the layout will not be done after onSplice_ because it is removed
511       // from this.tiles_.
512       if (this.tiles_[index + t].parentNode)
513         this.removeChild(this.tiles_[index + t]);
514     }
516     this.tiles_.splice(index, event.removed.length);
518     // No items left, show the banner.
519     if (this.getItemCount_() === 0)
520       this.errorBanner_.show('GALLERY_NO_IMAGES');
522     this.scheduleLayout(Mosaic.LAYOUT_DELAY);
523   }
525   if (event.added.length) {
526     var newTiles = [];
527     for (var t = 0; t !== event.added.length; t++) {
528       newTiles.push(new Mosaic.Tile(
529           this,
530           assertInstanceof(this.dataModel_.item(index + t),
531           Gallery.Item)));
532     }
534     this.tiles_.splice.apply(this.tiles_, [index, 0].concat(newTiles));
535     this.initTiles_(newTiles);
536     this.scheduleLayout(Mosaic.LAYOUT_DELAY);
537   }
539   if (this.tiles_.length !== this.dataModel_.length)
540     console.error('Mosaic is out of sync');
544  * Content change handler.
546  * @param {!Event} event Event.
547  * @private
548  */
549 Mosaic.prototype.onContentChange_ = function(event) {
550   if (!this.tiles_)
551     return;
553   if (!event.thumbnailChanged)
554     return; // Thumbnail unchanged, nothing to do.
556   var index = this.dataModel_.indexOf(event.item);
557   this.layoutModel_.invalidateFromTile_(index);
558   this.tiles_[index].init();
559   this.tiles_[index].unload();
560   this.tiles_[index].load(
561       Mosaic.Tile.LoadMode.HIGH_DPI,
562       this.scheduleLayout.bind(this, Mosaic.LAYOUT_DELAY));
566  * Keydown event handler.
568  * @param {!Event} event Event.
569  * @return {boolean} True if the event has been consumed.
570  */
571 Mosaic.prototype.onKeyDown = function(event) {
572   this.selectionController_.handleKeyDown(event);
573   if (event.defaultPrevented)  // Navigating with keyboard, hide hover state.
574     this.classList.remove('hover-visible');
575   return event.defaultPrevented;
579  * @return {boolean} True if the mosaic zoom effect can be applied. It is
580  * too slow if there are to many images.
581  * TODO(kaznacheev): Consider unloading the images that are out of the viewport.
582  */
583 Mosaic.prototype.canZoom = function() {
584   return this.tiles_.length < 100;
588  * Shows the mosaic.
589  */
590 Mosaic.prototype.show = function() {
591   // If the items are empty, just show the error message.
592   if (this.getItemCount_() === 0)
593     this.errorBanner_.show('GALLERY_NO_IMAGES');
595   var duration = ImageView.MODE_TRANSITION_DURATION;
596   if (this.canZoom()) {
597     // Fade in in parallel with the zoom effect.
598     this.setAttribute('visible', 'zooming');
599   } else {
600     // Mosaic is not animating but the large image is. Fade in the mosaic
601     // shortly before the large image animation is done.
602     duration -= 100;
603   }
604   this.showingTimeoutID_ = setTimeout(function() {
605     this.showingTimeoutID_ = 0;
606     // Make the selection visible.
607     // If the mosaic is not animated it will start fading in now.
608     this.setAttribute('visible', 'normal');
609     this.loadVisibleTiles_();
610   }.bind(this), duration);
614  * Hides the mosaic.
615  */
616 Mosaic.prototype.hide = function() {
617   this.errorBanner_.clear();
619   if (this.showingTimeoutID_ !== 0) {
620     clearTimeout(this.showingTimeoutID_);
621     this.showingTimeoutID_ = 0;
622   }
623   this.removeAttribute('visible');
627  * Checks if the mosaic view is visible.
628  * @return {boolean} True if visible, false otherwise.
629  * @private
630  */
631 Mosaic.prototype.isVisible_ = function() {
632   return this.hasAttribute('visible');
636  * Loads visible tiles. Ignores consecutive calls. Does not reload already
637  * loaded images.
638  * @private
639  */
640 Mosaic.prototype.loadVisibleTiles_ = function() {
641   if (this.loadVisibleTilesSuppressed_) {
642     this.loadVisibleTilesScheduled_ = true;
643     return;
644   }
646   this.loadVisibleTilesSuppressed_ = true;
647   this.loadVisibleTilesScheduled_ = false;
648   setTimeout(function() {
649     this.loadVisibleTilesSuppressed_ = false;
650     if (this.loadVisibleTilesScheduled_)
651       this.loadVisibleTiles_();
652   }.bind(this), 100);
654   // Tiles only in the viewport (visible).
655   var visibleRect = new ImageRect(
656       0, 0, this.clientWidth, this.clientHeight);
658   // Tiles in the viewport and also some distance on the left and right.
659   var renderableRect = new ImageRect(
660       -this.clientWidth,
661       0,
662       3 * this.clientWidth,
663       this.clientHeight);
665   // Unload tiles out of scope.
666   for (var index = 0; index < this.tiles_.length; index++) {
667     var tile = this.tiles_[index];
668     var imageRect = tile.getImageRect();
669     // Unload a thumbnail.
670     if (imageRect && !imageRect.intersects(renderableRect))
671       tile.unload();
672   }
674   // Load the visible tiles first.
675   var allVisibleLoaded = true;
676   // Show high-dpi only when the mosaic view is visible.
677   var loadMode = this.isVisible_() ? Mosaic.Tile.LoadMode.HIGH_DPI :
678       Mosaic.Tile.LoadMode.LOW_DPI;
679   for (var index = 0; index < this.tiles_.length; index++) {
680     var tile = this.tiles_[index];
681     var imageRect = tile.getImageRect();
682     // Load a thumbnail.
683     if (!tile.isLoading(loadMode) && !tile.isLoaded(loadMode) && imageRect &&
684         imageRect.intersects(visibleRect)) {
685       tile.load(loadMode, function() {});
686       allVisibleLoaded = false;
687     }
688   }
690   // Load also another, nearby, if the visible has been already loaded.
691   if (allVisibleLoaded) {
692     for (var index = 0; index < this.tiles_.length; index++) {
693       var tile = this.tiles_[index];
694       var imageRect = tile.getImageRect();
695       // Load a thumbnail.
696       if (!tile.isLoading() && !tile.isLoaded() && imageRect &&
697           imageRect.intersects(renderableRect)) {
698         tile.load(Mosaic.Tile.LoadMode.LOW_DPI, function() {});
699       }
700     }
701   }
705  * Applies reset the zoom transform.
707  * @param {ImageRect} tileRect Tile rectangle. Reset the transform if null.
708  * @param {ImageRect} imageRect Large image rectangle. Reset the transform if
709  *     null.
710  * @param {boolean=} opt_instant True of the transition should be instant.
711  */
712 Mosaic.prototype.transform = function(tileRect, imageRect, opt_instant) {
713   if (opt_instant) {
714     this.style.transitionDuration = '0';
715   } else {
716     this.style.transitionDuration =
717         ImageView.MODE_TRANSITION_DURATION + 'ms';
718   }
720   if (this.canZoom() && tileRect && imageRect) {
721     var scaleX = imageRect.width / tileRect.width;
722     var scaleY = imageRect.height / tileRect.height;
723     var shiftX = (imageRect.left + imageRect.width / 2) -
724         (tileRect.left + tileRect.width / 2);
725     var shiftY = (imageRect.top + imageRect.height / 2) -
726         (tileRect.top + tileRect.height / 2);
727     this.style.transform =
728         'translate(' + shiftX * scaleX + 'px, ' + shiftY * scaleY + 'px)' +
729         'scaleX(' + scaleX + ') scaleY(' + scaleY + ')';
730   } else {
731     this.style.transform = '';
732   }
736  * @return {number} Item count
737  * @private
738  */
739 Mosaic.prototype.getItemCount_ = function() {
740   return this.dataModel_.length;
744  * Enters the debug me.
745  */
746 Mosaic.prototype.debugMe = function() {
747   this.classList.add('debug-me');
750 ////////////////////////////////////////////////////////////////////////////////
753  * Creates a selection controller that is to be used with grid.
754  * @param {!cr.ui.ListSelectionModel} selectionModel The selection model to
755  *     interact with.
756  * @param {!Mosaic.Layout} layoutModel The layout model to use.
757  * @constructor
758  * @struct
759  * @extends {cr.ui.ListSelectionController}
760  * @suppress {checkStructDictInheritance}
761  */
762 Mosaic.SelectionController = function(selectionModel, layoutModel) {
763   cr.ui.ListSelectionController.call(this, selectionModel);
764   this.layoutModel_ = layoutModel;
768  * Extends cr.ui.ListSelectionController.
769  */
770 Mosaic.SelectionController.prototype.__proto__ =
771     cr.ui.ListSelectionController.prototype;
773 /** @override */
774 Mosaic.SelectionController.prototype.getLastIndex = function() {
775   return this.layoutModel_.getLaidOutTileCount() - 1;
778 /** @override */
779 Mosaic.SelectionController.prototype.getIndexBefore = function(index) {
780   return this.layoutModel_.getHorizontalAdjacentIndex(index, -1);
783 /** @override */
784 Mosaic.SelectionController.prototype.getIndexAfter = function(index) {
785   return this.layoutModel_.getHorizontalAdjacentIndex(index, 1);
788 /** @override */
789 Mosaic.SelectionController.prototype.getIndexAbove = function(index) {
790   return this.layoutModel_.getVerticalAdjacentIndex(index, -1);
793 /** @override */
794 Mosaic.SelectionController.prototype.getIndexBelow = function(index) {
795   return this.layoutModel_.getVerticalAdjacentIndex(index, 1);
798 ////////////////////////////////////////////////////////////////////////////////
801  * Mosaic layout.
803  * @param {string=} opt_mode Layout mode.
804  * @param {Mosaic.Density=} opt_maxDensity Layout density.
805  * @constructor
806  * @struct
807  */
808 Mosaic.Layout = function(opt_mode, opt_maxDensity) {
809   this.mode_ = opt_mode || Mosaic.Layout.Mode.TENTATIVE;
810   this.maxDensity_ = opt_maxDensity || Mosaic.Density.createHighest();
812   /**
813    * @type {!Array.<!Mosaic.Column>}
814    * @private
815    */
816   this.columns_ = [];
818   /**
819    * @type {Mosaic.Column}
820    * @private
821    */
822   this.newColumn_ = null;
824   /**
825    * @type {number}
826    * @private
827    */
828   this.viewportWidth_ = 0;
830   /**
831    * @type {number}
832    * @private
833    */
834   this.viewportHeight_ = 0;
836   /**
837    * @type {Mosaic.Density}
838    * @private
839    */
840   this.density_ = null;
842   this.reset_();
846  * Blank space at the top of the mosaic element. We do not do that in CSS
847  * to make transition effects easier.
848  * @type {number}
849  * @const
850  */
851 Mosaic.Layout.PADDING_TOP = 50;
854  * Blank space at the bottom of the mosaic element.
855  * @type {number}
856  * @const
857  */
858 Mosaic.Layout.PADDING_BOTTOM = 50;
861  * Horizontal and vertical spacing between images. Should be kept in sync
862  * with the style of .mosaic-item in gallery.css (= 2 * ( 4 + 1))
863  * @type {number}
864  * @const
865  */
866 Mosaic.Layout.SPACING = 10;
869  * Margin for scrolling using keyboard. Distance between a selected tile
870  * and window border.
871  * @type {number}
872  * @const
873  */
874 Mosaic.Layout.SCROLL_MARGIN = 30;
877  * Layout mode.
878  * @enum {string}
879  */
880 Mosaic.Layout.Mode = {
881   // Commit to DOM immediately.
882   FINAL: 'final',
883   // Do not commit layout to DOM until it is complete or the viewport
884   // overflows.
885   TENTATIVE: 'tentative',
886   // Never commit layout to DOM.
887   DRY_RUN: 'dry_run',
891  * Resets the layout.
893  * @private
894  */
895 Mosaic.Layout.prototype.reset_ = function() {
896   this.columns_ = [];
897   this.newColumn_ = null;
898   this.density_ = Mosaic.Density.createLowest();
899   if (this.mode_ !== Mosaic.Layout.Mode.DRY_RUN)  // DRY_RUN is sticky.
900     this.mode_ = Mosaic.Layout.Mode.TENTATIVE;
904  * @param {number} width Viewport width.
905  * @param {number} height Viewport height.
906  */
907 Mosaic.Layout.prototype.setViewportSize = function(width, height) {
908   this.viewportWidth_ = width;
909   this.viewportHeight_ = height;
910   this.reset_();
914  * @return {number} Total width of the layout.
915  */
916 Mosaic.Layout.prototype.getWidth = function() {
917   var lastColumn = this.getLastColumn_();
918   return lastColumn ? lastColumn.getRight() : 0;
922  * @return {number} Total height of the layout.
923  */
924 Mosaic.Layout.prototype.getHeight = function() {
925   var firstColumn = this.columns_[0];
926   return firstColumn ? firstColumn.getHeight() : 0;
930  * @return {!Array.<!Mosaic.Tile>} All tiles in the layout.
931  */
932 Mosaic.Layout.prototype.getTiles = function() {
933   return Array.prototype.concat.apply([],
934       this.columns_.map(function(c) { return c.getTiles(); }));
938  * @return {number} Total number of tiles added to the layout.
939  */
940 Mosaic.Layout.prototype.getTileCount = function() {
941   return this.getLaidOutTileCount() +
942       (this.newColumn_ ? this.newColumn_.getTileCount() : 0);
946  * @return {Mosaic.Column} The last column or null for empty layout.
947  * @private
948  */
949 Mosaic.Layout.prototype.getLastColumn_ = function() {
950   return this.columns_.length ? this.columns_[this.columns_.length - 1] : null;
954  * @return {number} Total number of tiles in completed columns.
955  */
956 Mosaic.Layout.prototype.getLaidOutTileCount = function() {
957   var lastColumn = this.getLastColumn_();
958   return lastColumn ? lastColumn.getNextTileIndex() : 0;
962  * Adds a tile to the layout.
964  * @param {!Mosaic.Tile} tile The tile to be added.
965  * @param {boolean} isLast True if this tile is the last.
966  */
967 Mosaic.Layout.prototype.add = function(tile, isLast) {
968   var layoutQueue = [tile];
970   // There are two levels of backtracking in the layout algorithm.
971   // |Mosaic.Layout.density_| tracks the state of the 'global' backtracking
972   // which aims to use as much of the viewport space as possible.
973   // It starts with the lowest density and increases it until the layout
974   // fits into the viewport. If it does not fit even at the highest density,
975   // the layout continues with the highest density.
976   //
977   // |Mosaic.Column.density_| tracks the state of the 'local' backtracking
978   // which aims to avoid producing unnaturally looking columns.
979   // It starts with the current global density and decreases it until the column
980   // looks nice.
982   while (layoutQueue.length) {
983     if (!this.newColumn_) {
984       var lastColumn = this.getLastColumn_();
985       this.newColumn_ = new Mosaic.Column(
986           this.columns_.length,
987           lastColumn ? lastColumn.getNextRowIndex() : 0,
988           lastColumn ? lastColumn.getNextTileIndex() : 0,
989           lastColumn ? lastColumn.getRight() : 0,
990           this.viewportHeight_,
991           this.density_.clone());
992     }
994     this.newColumn_.add(layoutQueue.shift());
996     var isFinalColumn = isLast && !layoutQueue.length;
998     if (!this.newColumn_.prepareLayout(isFinalColumn))
999       continue; // Column is incomplete.
1001     if (this.newColumn_.isSuboptimal()) {
1002       layoutQueue = this.newColumn_.getTiles().concat(layoutQueue);
1003       this.newColumn_.retryWithLowerDensity();
1004       continue;
1005     }
1007     this.columns_.push(this.newColumn_);
1008     this.newColumn_ = null;
1010     if (this.mode_ === Mosaic.Layout.Mode.FINAL && isFinalColumn) {
1011       this.commit_();
1012       continue;
1013     }
1015     if (this.getWidth() > this.viewportWidth_) {
1016       // Viewport completely filled.
1017       if (this.density_.equals(this.maxDensity_)) {
1018         // Max density reached, commit if tentative, just continue if dry run.
1019         if (this.mode_ === Mosaic.Layout.Mode.TENTATIVE)
1020           this.commit_();
1021         continue;
1022       }
1024       // Rollback the entire layout, retry with higher density.
1025       layoutQueue = this.getTiles().concat(layoutQueue);
1026       this.columns_ = [];
1027       this.density_.increase();
1028       continue;
1029     }
1031     if (isFinalColumn && this.mode_ === Mosaic.Layout.Mode.TENTATIVE) {
1032       // The complete tentative layout fits into the viewport.
1033       var stretched = this.findHorizontalLayout_();
1034       if (stretched)
1035         this.columns_ = stretched.columns_;
1036       // Center the layout in the viewport and commit.
1037       this.commit_((this.viewportWidth_ - this.getWidth()) / 2,
1038                    (this.viewportHeight_ - this.getHeight()) / 2);
1039     }
1040   }
1044  * Commits the tentative layout.
1046  * @param {number=} opt_offsetX Horizontal offset.
1047  * @param {number=} opt_offsetY Vertical offset.
1048  * @private
1049  */
1050 Mosaic.Layout.prototype.commit_ = function(opt_offsetX, opt_offsetY) {
1051   for (var i = 0; i !== this.columns_.length; i++) {
1052     this.columns_[i].layout(opt_offsetX, opt_offsetY);
1053   }
1054   this.mode_ = Mosaic.Layout.Mode.FINAL;
1058  * Finds the most horizontally stretched layout built from the same tiles.
1060  * The main layout algorithm fills the entire available viewport height.
1061  * If there is too few tiles this results in a layout that is unnaturally
1062  * stretched in the vertical direction.
1064  * This method tries a number of smaller heights and returns the most
1065  * horizontally stretched layout that still fits into the viewport.
1067  * @return {Mosaic.Layout} A horizontally stretched layout.
1068  * @private
1069  */
1070 Mosaic.Layout.prototype.findHorizontalLayout_ = function() {
1071   // If the layout aspect ratio is not dramatically different from
1072   // the viewport aspect ratio then there is no need to optimize.
1073   if (this.getWidth() / this.getHeight() >
1074       this.viewportWidth_ / this.viewportHeight_ * 0.9)
1075     return null;
1077   var tiles = this.getTiles();
1078   if (tiles.length === 1)
1079     return null;  // Single tile layout is always the same.
1081   var tileHeights = tiles.map(function(t) { return t.getMaxContentHeight(); });
1082   var minTileHeight = Math.min.apply(null, tileHeights);
1084   for (var h = minTileHeight; h < this.viewportHeight_; h += minTileHeight) {
1085     var layout = new Mosaic.Layout(
1086         Mosaic.Layout.Mode.DRY_RUN, this.density_.clone());
1087     layout.setViewportSize(this.viewportWidth_, h);
1088     for (var t = 0; t !== tiles.length; t++)
1089       layout.add(tiles[t], t + 1 === tiles.length);
1091     if (layout.getWidth() <= this.viewportWidth_)
1092       return layout;
1093   }
1095   return null;
1099  * Invalidates the layout after the given tile was modified (added, deleted or
1100  * changed dimensions).
1102  * @param {number} index Tile index.
1103  * @private
1104  */
1105 Mosaic.Layout.prototype.invalidateFromTile_ = function(index) {
1106   var columnIndex = this.getColumnIndexByTile_(index);
1107   if (columnIndex < 0)
1108     return; // Index not in the layout, probably already invalidated.
1110   if (this.columns_[columnIndex].getLeft() >= this.viewportWidth_) {
1111     // The columns to the right cover the entire viewport width, so there is no
1112     // chance that the modified layout would fit into the viewport.
1113     // No point in restarting the entire layout, keep the columns to the right.
1114     console.assert(this.mode_ === Mosaic.Layout.Mode.FINAL,
1115         'Expected FINAL layout mode');
1116     this.columns_ = this.columns_.slice(0, columnIndex);
1117     this.newColumn_ = null;
1118   } else {
1119     // There is a chance that the modified layout would fit into the viewport.
1120     this.reset_();
1121     this.mode_ = Mosaic.Layout.Mode.TENTATIVE;
1122   }
1126  * Gets the index of the tile to the left or to the right from the given tile.
1128  * @param {number} index Tile index.
1129  * @param {number} direction -1 for left, 1 for right.
1130  * @return {number} Adjacent tile index.
1131  */
1132 Mosaic.Layout.prototype.getHorizontalAdjacentIndex = function(
1133     index, direction) {
1134   var column = this.getColumnIndexByTile_(index);
1135   if (column < 0) {
1136     console.error('Cannot find column for tile #' + index);
1137     return -1;
1138   }
1140   var row = this.columns_[column].getRowByTileIndex(index);
1141   if (!row) {
1142     console.error('Cannot find row for tile #' + index);
1143     return -1;
1144   }
1146   var sameRowNeighbourIndex = index + direction;
1147   if (row.hasTile(sameRowNeighbourIndex))
1148     return sameRowNeighbourIndex;
1150   var adjacentColumn = column + direction;
1151   if (adjacentColumn < 0 || adjacentColumn === this.columns_.length)
1152     return -1;
1154   return this.columns_[adjacentColumn].
1155       getEdgeTileIndex_(row.getCenterY(), -direction);
1159  * Gets the index of the tile to the top or to the bottom from the given tile.
1161  * @param {number} index Tile index.
1162  * @param {number} direction -1 for above, 1 for below.
1163  * @return {number} Adjacent tile index.
1164  */
1165 Mosaic.Layout.prototype.getVerticalAdjacentIndex = function(
1166     index, direction) {
1167   var column = this.getColumnIndexByTile_(index);
1168   if (column < 0) {
1169     console.error('Cannot find column for tile #' + index);
1170     return -1;
1171   }
1173   var row = this.columns_[column].getRowByTileIndex(index);
1174   if (!row) {
1175     console.error('Cannot find row for tile #' + index);
1176     return -1;
1177   }
1179   // Find the first item in the next row, or the last item in the previous row.
1180   var adjacentRowNeighbourIndex =
1181       row.getEdgeTileIndex_(direction) + direction;
1183   if (adjacentRowNeighbourIndex < 0 ||
1184       adjacentRowNeighbourIndex > this.getTileCount() - 1)
1185     return -1;
1187   if (!this.columns_[column].hasTile(adjacentRowNeighbourIndex)) {
1188     // It is not in the current column, so return it.
1189     return adjacentRowNeighbourIndex;
1190   } else {
1191     // It is in the current column, so we have to find optically the closest
1192     // tile in the adjacent row.
1193     var adjacentRow = this.columns_[column].getRowByTileIndex(
1194         adjacentRowNeighbourIndex);
1195     var previousTileCenterX = row.getTileByIndex(index).getCenterX();
1197     // Find the closest one.
1198     var closestIndex = -1;
1199     var closestDistance;
1200     var adjacentRowTiles = adjacentRow.getTiles();
1201     for (var t = 0; t !== adjacentRowTiles.length; t++) {
1202       var distance =
1203           Math.abs(adjacentRowTiles[t].getCenterX() - previousTileCenterX);
1204       if (closestIndex === -1 || distance < closestDistance) {
1205         closestIndex = adjacentRow.getEdgeTileIndex_(-1) + t;
1206         closestDistance = distance;
1207       }
1208     }
1209     return closestIndex;
1210   }
1214  * @param {number} index Tile index.
1215  * @return {number} Index of the column containing the given tile.
1216  * @private
1217  */
1218 Mosaic.Layout.prototype.getColumnIndexByTile_ = function(index) {
1219   for (var c = 0; c !== this.columns_.length; c++) {
1220     if (this.columns_[c].hasTile(index))
1221       return c;
1222   }
1223   return -1;
1227  * Scales the given array of size values to satisfy 3 conditions:
1228  * 1. The new sizes must be integer.
1229  * 2. The new sizes must sum up to the given |total| value.
1230  * 3. The relative proportions of the sizes should be as close to the original
1231  *    as possible.
1233  * @param {!Array.<number>} sizes Array of sizes.
1234  * @param {number} newTotal New total size.
1235  */
1236 Mosaic.Layout.rescaleSizesToNewTotal = function(sizes, newTotal) {
1237   var total = 0;
1239   var partialTotals = [0];
1240   for (var i = 0; i !== sizes.length; i++) {
1241     total += sizes[i];
1242     partialTotals.push(total);
1243   }
1245   var scale = newTotal / total;
1247   for (i = 0; i !== sizes.length; i++) {
1248     sizes[i] = Math.round(partialTotals[i + 1] * scale) -
1249         Math.round(partialTotals[i] * scale);
1250   }
1253 ////////////////////////////////////////////////////////////////////////////////
1256  * Representation of the layout density.
1258  * @param {number} horizontal Horizontal density, number tiles per row.
1259  * @param {number} vertical Vertical density, frequency of rows forced to
1260  *   contain a single tile.
1261  * @constructor
1262  * @struct
1263  */
1264 Mosaic.Density = function(horizontal, vertical) {
1265   this.horizontal = horizontal;
1266   this.vertical = vertical;
1270  * Minimal horizontal density (tiles per row).
1271  * @type {number}
1272  * @const
1273  */
1274 Mosaic.Density.MIN_HORIZONTAL = 1;
1277  * Minimal horizontal density (tiles per row).
1278  * @type {number}
1279  * @const
1280  */
1281 Mosaic.Density.MAX_HORIZONTAL = 3;
1284  * Minimal vertical density: force 1 out of 2 rows to containt a single tile.
1285  * @type {number}
1286  * @const
1287  */
1288 Mosaic.Density.MIN_VERTICAL = 2;
1291  * Maximal vertical density: force 1 out of 3 rows to containt a single tile.
1292  * @type {number}
1293  * @const
1294  */
1295 Mosaic.Density.MAX_VERTICAL = 3;
1298  * @return {!Mosaic.Density} Lowest density.
1299  */
1300 Mosaic.Density.createLowest = function() {
1301   return new Mosaic.Density(
1302       Mosaic.Density.MIN_HORIZONTAL,
1303       Mosaic.Density.MIN_VERTICAL /* ignored when horizontal is at min */);
1307  * @return {!Mosaic.Density} Highest density.
1308  */
1309 Mosaic.Density.createHighest = function() {
1310   return new Mosaic.Density(
1311       Mosaic.Density.MAX_HORIZONTAL,
1312       Mosaic.Density.MAX_VERTICAL);
1316  * @return {!Mosaic.Density} A clone of this density object.
1317  */
1318 Mosaic.Density.prototype.clone = function() {
1319   return new Mosaic.Density(this.horizontal, this.vertical);
1323  * @param {!Mosaic.Density} that The other object.
1324  * @return {boolean} True if equal.
1325  */
1326 Mosaic.Density.prototype.equals = function(that) {
1327   return this.horizontal === that.horizontal &&
1328          this.vertical === that.vertical;
1332  * Increases the density to the next level.
1333  */
1334 Mosaic.Density.prototype.increase = function() {
1335   if (this.horizontal === Mosaic.Density.MIN_HORIZONTAL ||
1336       this.vertical === Mosaic.Density.MAX_VERTICAL) {
1337     console.assert(this.horizontal < Mosaic.Density.MAX_HORIZONTAL);
1338     this.horizontal++;
1339     this.vertical = Mosaic.Density.MIN_VERTICAL;
1340   } else {
1341     this.vertical++;
1342   }
1346  * Decreases horizontal density.
1347  */
1348 Mosaic.Density.prototype.decreaseHorizontal = function() {
1349   console.assert(this.horizontal > Mosaic.Density.MIN_HORIZONTAL);
1350   this.horizontal--;
1354  * @param {number} tileCount Number of tiles in the row.
1355  * @param {number} rowIndex Global row index.
1356  * @return {boolean} True if the row is complete.
1357  */
1358 Mosaic.Density.prototype.isRowComplete = function(tileCount, rowIndex) {
1359   return (tileCount === this.horizontal) || (rowIndex % this.vertical) === 0;
1362 ////////////////////////////////////////////////////////////////////////////////
1365  * A column in a mosaic layout. Contains rows.
1367  * @param {number} index Column index.
1368  * @param {number} firstRowIndex Global row index.
1369  * @param {number} firstTileIndex Index of the first tile in the column.
1370  * @param {number} left Left edge coordinate.
1371  * @param {number} maxHeight Maximum height.
1372  * @param {!Mosaic.Density} density Layout density.
1373  * @constructor
1374  * @struct
1375  */
1376 Mosaic.Column = function(index, firstRowIndex, firstTileIndex, left, maxHeight,
1377                          density) {
1378   this.index_ = index;
1379   this.firstRowIndex_ = firstRowIndex;
1380   this.firstTileIndex_ = firstTileIndex;
1381   this.left_ = left;
1382   this.maxHeight_ = maxHeight;
1383   this.density_ = density;
1385   /**
1386    * @type {number}
1387    * @private
1388    */
1389   this.width_ = 0;
1391   /**
1392    * @type {!Array.<!Mosaic.Tile>}
1393    * @private
1394    */
1395   this.tiles_ = [];
1397   /**
1398    * @type {!Array.<!Mosaic.Row>}
1399    * @private
1400    */
1401   this.rows_ = [];
1403   /**
1404    * @type {Mosaic.Row}
1405    * @private
1406    */
1407   this.newRow_ = null;
1409   /**
1410    * @type {!Array.<number>}
1411    * @private
1412    */
1413   this.rowHeights_ = [];
1415   /**
1416    * @type {number}
1417    * @private
1418    */
1419   this.height_ = 0;
1421   this.reset_();
1425  * Resets the layout.
1426  * @private
1427  */
1428 Mosaic.Column.prototype.reset_ = function() {
1429   this.tiles_ = [];
1430   this.rows_ = [];
1431   this.newRow_ = null;
1435  * @return {number} Number of tiles in the column.
1436  */
1437 Mosaic.Column.prototype.getTileCount = function() { return this.tiles_.length };
1440  * @return {number} Index of the last tile + 1.
1441  */
1442 Mosaic.Column.prototype.getNextTileIndex = function() {
1443   return this.firstTileIndex_ + this.getTileCount();
1447  * @return {number} Global index of the last row + 1.
1448  */
1449 Mosaic.Column.prototype.getNextRowIndex = function() {
1450   return this.firstRowIndex_ + this.rows_.length;
1454  * @return {!Array.<!Mosaic.Tile>} Array of tiles in the column.
1455  */
1456 Mosaic.Column.prototype.getTiles = function() { return this.tiles_ };
1459  * @param {number} index Tile index.
1460  * @return {boolean} True if this column contains the tile with the given index.
1461  */
1462 Mosaic.Column.prototype.hasTile = function(index) {
1463   return this.firstTileIndex_ <= index &&
1464       index < (this.firstTileIndex_ + this.getTileCount());
1468  * @param {number} y Y coordinate.
1469  * @param {number} direction -1 for left, 1 for right.
1470  * @return {number} Index of the tile lying on the edge of the column at the
1471  *    given y coordinate.
1472  * @private
1473  */
1474 Mosaic.Column.prototype.getEdgeTileIndex_ = function(y, direction) {
1475   for (var r = 0; r < this.rows_.length; r++) {
1476     if (this.rows_[r].coversY(y))
1477       return this.rows_[r].getEdgeTileIndex_(direction);
1478   }
1479   return -1;
1483  * @param {number} index Tile index.
1484  * @return {Mosaic.Row} The row containing the tile with a given index.
1485  */
1486 Mosaic.Column.prototype.getRowByTileIndex = function(index) {
1487   for (var r = 0; r !== this.rows_.length; r++) {
1488     if (this.rows_[r].hasTile(index))
1489       return this.rows_[r];
1490   }
1491   return null;
1495  * Adds a tile to the column.
1497  * @param {!Mosaic.Tile} tile The tile to add.
1498  */
1499 Mosaic.Column.prototype.add = function(tile) {
1500   var rowIndex = this.getNextRowIndex();
1502   if (!this.newRow_)
1503     this.newRow_ = new Mosaic.Row(this.getNextTileIndex());
1505   this.tiles_.push(tile);
1506   this.newRow_.add(tile);
1508   if (this.density_.isRowComplete(this.newRow_.getTileCount(), rowIndex)) {
1509     this.rows_.push(this.newRow_);
1510     this.newRow_ = null;
1511   }
1515  * Prepares the column layout.
1517  * @param {boolean=} opt_force True if the layout must be performed even for an
1518  *   incomplete column.
1519  * @return {boolean} True if the layout was performed.
1520  */
1521 Mosaic.Column.prototype.prepareLayout = function(opt_force) {
1522   if (opt_force && this.newRow_) {
1523     this.rows_.push(this.newRow_);
1524     this.newRow_ = null;
1525   }
1527   if (this.rows_.length === 0)
1528     return false;
1530   this.width_ = Math.min.apply(
1531       null, this.rows_.map(function(row) { return row.getMaxWidth() }));
1533   this.height_ = 0;
1535   this.rowHeights_ = [];
1536   for (var r = 0; r !== this.rows_.length; r++) {
1537     var rowHeight = this.rows_[r].getHeightForWidth(this.width_);
1538     this.height_ += rowHeight;
1539     this.rowHeights_.push(rowHeight);
1540   }
1542   var overflow = this.height_ / this.maxHeight_;
1543   if (!opt_force && (overflow < 1))
1544     return false;
1546   if (overflow > 1) {
1547     // Scale down the column width and height.
1548     this.width_ = Math.round(this.width_ / overflow);
1549     this.height_ = this.maxHeight_;
1550     Mosaic.Layout.rescaleSizesToNewTotal(this.rowHeights_, this.maxHeight_);
1551   }
1553   return true;
1557  * Retries the column layout with less tiles per row.
1558  */
1559 Mosaic.Column.prototype.retryWithLowerDensity = function() {
1560   this.density_.decreaseHorizontal();
1561   this.reset_();
1565  * @return {number} Column left edge coordinate.
1566  */
1567 Mosaic.Column.prototype.getLeft = function() { return this.left_ };
1570  * @return {number} Column right edge coordinate after the layout.
1571  */
1572 Mosaic.Column.prototype.getRight = function() {
1573   return this.left_ + this.width_;
1577  * @return {number} Column height after the layout.
1578  */
1579 Mosaic.Column.prototype.getHeight = function() { return this.height_ };
1582  * Performs the column layout.
1583  * @param {number=} opt_offsetX Horizontal offset.
1584  * @param {number=} opt_offsetY Vertical offset.
1585  */
1586 Mosaic.Column.prototype.layout = function(opt_offsetX, opt_offsetY) {
1587   opt_offsetX = opt_offsetX || 0;
1588   opt_offsetY = opt_offsetY || 0;
1589   var rowTop = Mosaic.Layout.PADDING_TOP;
1590   for (var r = 0; r !== this.rows_.length; r++) {
1591     this.rows_[r].layout(
1592         opt_offsetX + this.left_,
1593         opt_offsetY + rowTop,
1594         this.width_,
1595         this.rowHeights_[r]);
1596     rowTop += this.rowHeights_[r];
1597   }
1601  * Checks if the column layout is too ugly to be displayed.
1603  * @return {boolean} True if the layout is suboptimal.
1604  */
1605 Mosaic.Column.prototype.isSuboptimal = function() {
1606   var tileCounts =
1607       this.rows_.map(function(row) { return row.getTileCount() });
1609   var maxTileCount = Math.max.apply(null, tileCounts);
1610   if (maxTileCount === 1)
1611     return false;  // Every row has exactly 1 tile, as optimal as it gets.
1613   var sizes =
1614       this.tiles_.map(function(tile) { return tile.getMaxContentHeight() });
1616   // Ugly layout #1: all images are small and some are one the same row.
1617   var allSmall = Math.max.apply(null, sizes) <= Mosaic.Tile.SMALL_IMAGE_SIZE;
1618   if (allSmall)
1619     return true;
1621   // Ugly layout #2: all images are large and none occupies an entire row.
1622   var allLarge = Math.min.apply(null, sizes) > Mosaic.Tile.SMALL_IMAGE_SIZE;
1623   var allCombined = Math.min.apply(null, tileCounts) !== 1;
1624   if (allLarge && allCombined)
1625     return true;
1627   // Ugly layout #3: some rows have too many tiles for the resulting width.
1628   if (this.width_ / maxTileCount < 100)
1629     return true;
1631   return false;
1634 ////////////////////////////////////////////////////////////////////////////////
1637  * A row in a mosaic layout. Contains tiles.
1639  * @param {number} firstTileIndex Index of the first tile in the row.
1640  * @constructor
1641  * @struct
1642  */
1643 Mosaic.Row = function(firstTileIndex) {
1644   this.firstTileIndex_ = firstTileIndex;
1645   this.tiles_ = [];
1647   /**
1648    * @type {number}
1649    * @private
1650    */
1651   this.top_ = 0;
1653   /**
1654    * @type {number}
1655    * @private
1656    */
1657   this.height_ = 0;
1661  * @param {!Mosaic.Tile} tile The tile to add.
1662  */
1663 Mosaic.Row.prototype.add = function(tile) {
1664   console.assert(this.getTileCount() < Mosaic.Density.MAX_HORIZONTAL);
1665   this.tiles_.push(tile);
1669  * @return {!Array.<!Mosaic.Tile>} Array of tiles in the row.
1670  */
1671 Mosaic.Row.prototype.getTiles = function() { return this.tiles_ };
1674  * Gets a tile by index.
1675  * @param {number} index Tile index.
1676  * @return {Mosaic.Tile} Requested tile or null if not found.
1677  */
1678 Mosaic.Row.prototype.getTileByIndex = function(index) {
1679   if (!this.hasTile(index))
1680     return null;
1681   return this.tiles_[index - this.firstTileIndex_];
1686  * @return {number} Number of tiles in the row.
1687  */
1688 Mosaic.Row.prototype.getTileCount = function() { return this.tiles_.length };
1691  * @param {number} index Tile index.
1692  * @return {boolean} True if this row contains the tile with the given index.
1693  */
1694 Mosaic.Row.prototype.hasTile = function(index) {
1695   return this.firstTileIndex_ <= index &&
1696       index < (this.firstTileIndex_ + this.tiles_.length);
1700  * @param {number} y Y coordinate.
1701  * @return {boolean} True if this row covers the given Y coordinate.
1702  */
1703 Mosaic.Row.prototype.coversY = function(y) {
1704   return this.top_ <= y && y < (this.top_ + this.height_);
1708  * @return {number} Y coordinate of the tile center.
1709  */
1710 Mosaic.Row.prototype.getCenterY = function() {
1711   return this.top_ + Math.round(this.height_ / 2);
1715  * Gets the first or the last tile.
1717  * @param {number} direction -1 for the first tile, 1 for the last tile.
1718  * @return {number} Tile index.
1719  * @private
1720  */
1721 Mosaic.Row.prototype.getEdgeTileIndex_ = function(direction) {
1722   if (direction < 0)
1723     return this.firstTileIndex_;
1724   else
1725     return this.firstTileIndex_ + this.getTileCount() - 1;
1729  * @return {number} Aspect ration of the combined content box of this row.
1730  * @private
1731  */
1732 Mosaic.Row.prototype.getTotalContentAspectRatio_ = function() {
1733   var sum = 0;
1734   for (var t = 0; t !== this.tiles_.length; t++)
1735     sum += this.tiles_[t].getAspectRatio();
1736   return sum;
1740  * @return {number} Total horizontal spacing in this row. This includes
1741  *   the spacing between the tiles and both left and right margins.
1743  * @private
1744  */
1745 Mosaic.Row.prototype.getTotalHorizontalSpacing_ = function() {
1746   return Mosaic.Layout.SPACING * this.getTileCount();
1750  * @return {number} Maximum width that this row may have without overscaling
1751  * any of the tiles.
1752  */
1753 Mosaic.Row.prototype.getMaxWidth = function() {
1754   var contentHeight = Math.min.apply(null,
1755       this.tiles_.map(function(tile) { return tile.getMaxContentHeight() }));
1757   var contentWidth =
1758       Math.round(contentHeight * this.getTotalContentAspectRatio_());
1759   return contentWidth + this.getTotalHorizontalSpacing_();
1763  * Computes the height that best fits the supplied row width given
1764  * aspect ratios of the tiles in this row.
1766  * @param {number} width Row width.
1767  * @return {number} Height.
1768  */
1769 Mosaic.Row.prototype.getHeightForWidth = function(width) {
1770   var contentWidth = width - this.getTotalHorizontalSpacing_();
1771   var contentHeight =
1772       Math.round(contentWidth / this.getTotalContentAspectRatio_());
1773   return contentHeight + Mosaic.Layout.SPACING;
1777  * Positions the row in the mosaic.
1779  * @param {number} left Left position.
1780  * @param {number} top Top position.
1781  * @param {number} width Width.
1782  * @param {number} height Height.
1783  */
1784 Mosaic.Row.prototype.layout = function(left, top, width, height) {
1785   this.top_ = top;
1786   this.height_ = height;
1788   var contentWidth = width - this.getTotalHorizontalSpacing_();
1789   var contentHeight = height - Mosaic.Layout.SPACING;
1791   var tileContentWidth = this.tiles_.map(
1792       function(tile) { return tile.getAspectRatio() });
1794   Mosaic.Layout.rescaleSizesToNewTotal(tileContentWidth, contentWidth);
1796   var tileLeft = left;
1797   for (var t = 0; t !== this.tiles_.length; t++) {
1798     var tileWidth = tileContentWidth[t] + Mosaic.Layout.SPACING;
1799     this.tiles_[t].layout(tileLeft, top, tileWidth, height);
1800     tileLeft += tileWidth;
1801   }
1804 ////////////////////////////////////////////////////////////////////////////////
1807  * A single tile of the image mosaic.
1809  * @param {!Element} container Container element.
1810  * @param {!Gallery.Item} item Gallery item associated with this tile.
1811  * @param {EntryLocation=} opt_locationInfo Location information for the tile.
1812  * @return {!Element} The new tile element.
1813  * @constructor
1814  * @extends {HTMLDivElement}
1815  * @struct
1816  * @suppress {checkStructDictInheritance}
1817  */
1818 Mosaic.Tile = function(container, item, opt_locationInfo) {
1819   // This is a hack to make closure compiler recognize definitions of fields
1820   // with this decorate pattern. When this constructor is called as "new
1821   // Mosaic.Tile(...)", "this" should be Mosaic.Tile. In that case, this calls
1822   // this constructor again with setting this as HTMLDivElement. When this
1823   // condition is false, this method decorates the "this" object, and returns
1824   // it.
1825   if (this instanceof Mosaic.Tile) {
1826     return Mosaic.Tile.call(
1827         /** @type {Mosaic.Tile} */ (document.createElement('div')),
1828         container, item, opt_locationInfo);
1829   }
1831   this.__proto__ = Mosaic.Tile.prototype;
1832   this.className = 'mosaic-tile';
1834   /**
1835    * @type {!Element}
1836    * @private
1837    */
1838   this.container_ = container;
1840   /**
1841    * @type {!Gallery.Item}
1842    * @private
1843    */
1844   this.item_ = item;
1846   /**
1847    * @type {?number}
1848    * @private
1849    */
1850   this.left_ = null; // Mark as not laid out.
1852   /**
1853    * @type {number}
1854    * @private
1855    */
1856   this.top_ = 0;
1858   /**
1859    * @type {number}
1860    * @private
1861    */
1862   this.width_ = 0;
1864   /**
1865    * @type {number}
1866    * @private
1867    */
1868   this.height_ = 0;
1870   /**
1871    * @type {number}
1872    * @private
1873    */
1874   this.maxContentHeight_ = 0;
1876   /**
1877    * @type {number}
1878    * @private
1879    */
1880   this.aspectRatio_ = 0;
1882   /**
1883    * @type {ThumbnailLoader}
1884    * @private
1885    */
1886   this.thumbnailPreloader_ = null;
1888   /**
1889    * @type {ThumbnailLoader}
1890    * @private
1891    */
1892   this.thumbnailLoader_ = null;
1894   /**
1895    * @type {boolean}
1896    * @private
1897    */
1898   this.imagePreloaded_ = false;
1900   /**
1901    * @type {boolean}
1902    * @private
1903    */
1904   this.imageLoaded_ = false;
1906   /**
1907    * @type {boolean}
1908    * @private
1909    */
1910   this.imagePreloading_ = false;
1912   /**
1913    * @type {boolean}
1914    * @private
1915    */
1916   this.imageLoading_ = false;
1918   /**
1919    * @type {HTMLDivElement}
1920    * @private
1921    */
1922   this.wrapper_ = null;
1924   return this;
1928  * Load mode for the tile's image.
1929  * @enum {number}
1930  */
1931 Mosaic.Tile.LoadMode = {
1932   LOW_DPI: 0,
1933   HIGH_DPI: 1
1937 * Inherit from HTMLDivElement.
1939 Mosaic.Tile.prototype.__proto__ = HTMLDivElement.prototype;
1942  * Minimum tile content size.
1943  * @type {number}
1944  * @const
1945  */
1946 Mosaic.Tile.MIN_CONTENT_SIZE = 64;
1949  * Maximum tile content size.
1950  * @type {number}
1951  * @const
1952  */
1953 Mosaic.Tile.MAX_CONTENT_SIZE = 512;
1956  * Default size for a tile with no thumbnail image.
1957  * @type {number}
1958  * @const
1959  */
1960 Mosaic.Tile.GENERIC_ICON_SIZE = 128;
1963  * Max size of an image considered to be 'small'.
1964  * Small images are laid out slightly differently.
1965  * @type {number}
1966  * @const
1967  */
1968 Mosaic.Tile.SMALL_IMAGE_SIZE = 160;
1971  * @return {!Gallery.Item} The Gallery item.
1972  */
1973 Mosaic.Tile.prototype.getItem = function() { return this.item_; };
1976  * @return {number} Maximum content height that this tile can have.
1977  */
1978 Mosaic.Tile.prototype.getMaxContentHeight = function() {
1979   return this.maxContentHeight_;
1983  * @return {number} The aspect ratio of the tile image.
1984  */
1985 Mosaic.Tile.prototype.getAspectRatio = function() { return this.aspectRatio_; };
1988  * @return {boolean} True if the tile is initialized.
1989  */
1990 Mosaic.Tile.prototype.isInitialized = function() {
1991   return !!this.maxContentHeight_;
1995  * Checks whether the image of specified (or better resolution) has been loaded.
1997  * @param {Mosaic.Tile.LoadMode=} opt_loadMode Loading mode, default: LOW_DPI.
1998  * @return {boolean} True if the tile is loaded with the specified dpi or
1999  *     better.
2000  */
2001 Mosaic.Tile.prototype.isLoaded = function(opt_loadMode) {
2002   var loadMode = opt_loadMode || Mosaic.Tile.LoadMode.LOW_DPI;
2003   switch (loadMode) {
2004     case Mosaic.Tile.LoadMode.LOW_DPI:
2005       if (this.imagePreloaded_ || this.imageLoaded_)
2006         return true;
2007       break;
2008     case Mosaic.Tile.LoadMode.HIGH_DPI:
2009       if (this.imageLoaded_)
2010         return true;
2011       break;
2012   }
2013   return false;
2017  * Checks whether the image of specified (or better resolution) is being loaded.
2019  * @param {Mosaic.Tile.LoadMode=} opt_loadMode Loading mode, default: LOW_DPI.
2020  * @return {boolean} True if the tile is being loaded with the specified dpi or
2021  *     better.
2022  */
2023 Mosaic.Tile.prototype.isLoading = function(opt_loadMode) {
2024   var loadMode = opt_loadMode || Mosaic.Tile.LoadMode.LOW_DPI;
2025   switch (loadMode) {
2026     case Mosaic.Tile.LoadMode.LOW_DPI:
2027       if (this.imagePreloading_ || this.imageLoading_)
2028         return true;
2029       break;
2030     case Mosaic.Tile.LoadMode.HIGH_DPI:
2031       if (this.imageLoading_)
2032         return true;
2033       break;
2034   }
2035   return false;
2039  * Marks the tile as not loaded to prevent it from participating in the layout.
2040  */
2041 Mosaic.Tile.prototype.markUnloaded = function() {
2042   this.maxContentHeight_ = 0;
2043   if (this.thumbnailLoader_) {
2044     this.thumbnailLoader_.cancel();
2045     this.imagePreloaded_ = false;
2046     this.imagePreloading_ = false;
2047     this.imageLoaded_ = false;
2048     this.imageLoading_ = false;
2049   }
2053  * Initializes the thumbnail in the tile. Does not load an image, but sets
2054  * target dimensions using metadata.
2055  */
2056 Mosaic.Tile.prototype.init = function() {
2057   this.markUnloaded();
2058   this.left_ = null;  // Mark as not laid out.
2060   // Set higher priority for the selected elements to load them first.
2061   var priority = this.getAttribute('selected') ? 2 : 3;
2063   if (this.getItem().getThumbnailMetadataItem()) {
2064     // Use embedded thumbnails on Drive, since they have higher resolution.
2065     this.thumbnailLoader_ = new ThumbnailLoader(
2066         this.getItem().getEntry(),
2067         ThumbnailLoader.LoaderType.CANVAS,
2068         this.getItem().getThumbnailMetadataItem(),
2069         undefined,  // Media type.
2070         [
2071           ThumbnailLoader.LoadTarget.EXTERNAL_METADATA,
2072           ThumbnailLoader.LoadTarget.FILE_ENTRY
2073         ]);
2075     // If no hidpi embedded thumbnail available, then use the low resolution
2076     // for preloading.
2077     if (this.thumbnailLoader_.getLoadTarget() ===
2078         ThumbnailLoader.LoadTarget.FILE_ENTRY) {
2079       this.thumbnailPreloader_ = new ThumbnailLoader(
2080           this.getItem().getEntry(),
2081           ThumbnailLoader.LoaderType.CANVAS,
2082           this.getItem().getThumbnailMetadataItem(),
2083           undefined,  // Media type.
2084           [
2085             ThumbnailLoader.LoadTarget.CONTENT_METADATA
2086           ],
2087           // Preloaders have always higher priotity, so the preload images
2088           // are loaded as soon as possible.
2089           2);
2090       if (!this.thumbnailPreloader_.getLoadTarget())
2091         this.thumbnailPreloader_ = null;
2092     }
2093   }
2095   // Dimensions are always acquired from the metadata. For local files, it is
2096   // extracted from headers. For Drive files, it is received via the Drive API.
2097   // If the dimensions are not available, then the fallback dimensions will be
2098   // used (same as for the generic icon).
2099   var metadataItem = this.getItem().getMetadataItem();
2100   var width;
2101   var height;
2102   if (metadataItem && metadataItem.imageWidth && metadataItem.imageHeight) {
2103     width = metadataItem.imageWidth;
2104     height = metadataItem.imageHeight;
2105   } else {
2106     // No dimensions in metadata, then use the generic dimensions.
2107     width = Mosaic.Tile.GENERIC_ICON_SIZE;
2108     height = Mosaic.Tile.GENERIC_ICON_SIZE;
2109   }
2111   if (width > height) {
2112     if (width > Mosaic.Tile.MAX_CONTENT_SIZE) {
2113       height = Math.round(height * Mosaic.Tile.MAX_CONTENT_SIZE / width);
2114       width = Mosaic.Tile.MAX_CONTENT_SIZE;
2115     }
2116   } else {
2117     if (height > Mosaic.Tile.MAX_CONTENT_SIZE) {
2118       width = Math.round(width * Mosaic.Tile.MAX_CONTENT_SIZE / height);
2119       height = Mosaic.Tile.MAX_CONTENT_SIZE;
2120     }
2121   }
2122   this.maxContentHeight_ = Math.max(Mosaic.Tile.MIN_CONTENT_SIZE, height);
2123   this.aspectRatio_ = width / height;
2127  * Loads an image into the tile.
2129  * The mode argument is a hint. Use low-dpi for faster response, and high-dpi
2130  * for better output, but possibly affecting performance.
2132  * If the mode is high-dpi, then a the high-dpi image is loaded, but also
2133  * low-dpi image is loaded for preloading (if available).
2134  * For the low-dpi mode, only low-dpi image is loaded. If not available, then
2135  * the high-dpi image is loaded as a fallback.
2137  * @param {!Mosaic.Tile.LoadMode} loadMode Loading mode.
2138  * @param {function(boolean)} onImageLoaded Callback when image is loaded.
2139  *     The argument is true for success, false for failure.
2140  */
2141 Mosaic.Tile.prototype.load = function(loadMode, onImageLoaded) {
2142   // Attaches the image to the tile and finalizes loading process for the
2143   // specified loader.
2144   var finalizeLoader = function(mode, success, loader) {
2145     if (success && this.wrapper_) {
2146       // Show the fade-in animation only when previously there was no image
2147       // attached in this tile.
2148       if (!this.imageLoaded_ && !this.imagePreloaded_)
2149         this.wrapper_.classList.add('animated');
2150       else
2151         this.wrapper_.classList.remove('animated');
2153       // Add debug mode classes.
2154       this.wrapper_.classList.remove('load-target-content-metadata');
2155       this.wrapper_.classList.remove('load-target-external-metadata');
2156       this.wrapper_.classList.remove('load-target-file-entry');
2157       switch (loader.getLoadTarget()) {
2158         case ThumbnailLoader.LoadTarget.CONTENT_METADATA:
2159           this.wrapper_.classList.add('load-target-content-metadata');
2160           break;
2161         case ThumbnailLoader.LoadTarget.EXTERNAL_METADATA:
2162           this.wrapper_.classList.add('load-target-external-metadata');
2163           break;
2164         case ThumbnailLoader.LoadTarget.FILE_ENTRY:
2165           this.wrapper_.classList.add('load-target-file-entry');
2166               break;
2167       }
2169       loader.attachImage(this.wrapper_, ThumbnailLoader.FillMode.OVER_FILL);
2170     }
2172     onImageLoaded(success);
2174     switch (mode) {
2175       case Mosaic.Tile.LoadMode.LOW_DPI:
2176         this.imagePreloading_ = false;
2177         this.imagePreloaded_ = true;
2178         break;
2179       case Mosaic.Tile.LoadMode.HIGH_DPI:
2180         this.imageLoading_ = false;
2181         this.imageLoaded_ = true;
2182         break;
2183     }
2184   }.bind(this);
2186   // Always load the low-dpi image first if it is available for the fastest
2187   // feedback.
2188   if (!this.imagePreloading_ && this.thumbnailPreloader_) {
2189     this.imagePreloading_ = true;
2190     this.thumbnailPreloader_.loadDetachedImage(function(success) {
2191       // Hi-dpi loaded first, ignore this call then.
2192       if (this.imageLoaded_)
2193         return;
2194       finalizeLoader(Mosaic.Tile.LoadMode.LOW_DPI,
2195                      success,
2196                      this.thumbnailPreloader_);
2197     }.bind(this));
2198   }
2200   // Load the high-dpi image only when it is requested, or the low-dpi is not
2201   // available.
2202   if (!this.imageLoading_ && this.thumbnailLoader_ &&
2203       (loadMode === Mosaic.Tile.LoadMode.HIGH_DPI || !this.imagePreloading_)) {
2204     this.imageLoading_ = true;
2205     this.thumbnailLoader_.loadDetachedImage(function(success) {
2206       // Cancel preloading, since the hi-dpi image is ready.
2207       if (this.thumbnailPreloader_)
2208         this.thumbnailPreloader_.cancel();
2209       finalizeLoader(Mosaic.Tile.LoadMode.HIGH_DPI,
2210                      success,
2211                      this.thumbnailLoader_);
2212     }.bind(this));
2213   }
2217  * Unloads an image from the tile.
2218  */
2219 Mosaic.Tile.prototype.unload = function() {
2220   if (this.thumbnailLoader_)
2221     this.thumbnailLoader_.cancel();
2222   if (this.thumbnailPreloader_)
2223     this.thumbnailPreloader_.cancel();
2224   this.imagePreloaded_ = false;
2225   this.imageLoaded_ = false;
2226   this.imagePreloading_ = false;
2227   this.imageLoading_ = false;
2228   if (this.wrapper_)
2229     this.wrapper_.innerText = '';
2233  * Selects/unselects the tile.
2235  * @param {boolean} on True if selected.
2236  */
2237 Mosaic.Tile.prototype.select = function(on) {
2238   if (on)
2239     this.setAttribute('selected', true);
2240   else
2241     this.removeAttribute('selected');
2245  * Positions the tile in the mosaic.
2247  * @param {number} left Left position.
2248  * @param {number} top Top position.
2249  * @param {number} width Width.
2250  * @param {number} height Height.
2251  */
2252 Mosaic.Tile.prototype.layout = function(left, top, width, height) {
2253   this.left_ = left;
2254   this.top_ = top;
2255   this.width_ = width;
2256   this.height_ = height;
2258   this.style.left = left + 'px';
2259   this.style.top = top + 'px';
2260   this.style.width = width + 'px';
2261   this.style.height = height + 'px';
2263   if (!this.wrapper_) {  // First time, create DOM.
2264     this.container_.appendChild(this);
2265     var border = util.createChild(this, 'img-border');
2266     this.wrapper_ = assertInstanceof(util.createChild(border, 'img-wrapper'),
2267         HTMLDivElement);
2268   }
2269   if (this.hasAttribute('selected'))
2270     this.scrollIntoView(false);
2272   if (this.imageLoaded_) {
2273     this.thumbnailLoader_.attachImage(this.wrapper_,
2274                                       ThumbnailLoader.FillMode.OVER_FILL);
2275   }
2279  * If the tile is not fully visible scroll the parent to make it fully visible.
2280  * @param {boolean=} opt_animated True, if scroll should be animated,
2281  *     default: true.
2282  */
2283 Mosaic.Tile.prototype.scrollIntoView = function(opt_animated) {
2284   if (this.left_ === null)  // Not laid out.
2285     return;
2287   var targetPosition;
2288   var tileLeft = this.left_ - Mosaic.Layout.SCROLL_MARGIN;
2289   if (tileLeft < this.container_.scrollLeft) {
2290     targetPosition = tileLeft;
2291   } else {
2292     var tileRight = this.left_ + this.width_ + Mosaic.Layout.SCROLL_MARGIN;
2293     var scrollRight = this.container_.scrollLeft + this.container_.clientWidth;
2294     if (tileRight > scrollRight)
2295       targetPosition = tileRight - this.container_.clientWidth;
2296   }
2298   if (targetPosition) {
2299     if (opt_animated === false)
2300       this.container_.scrollLeft = targetPosition;
2301     else
2302       this.container_.animatedScrollTo(targetPosition);
2303   }
2307  * @return {ImageRect} Rectangle occupied by the tile's image,
2308  *   relative to the viewport.
2309  */
2310 Mosaic.Tile.prototype.getImageRect = function() {
2311   if (this.left_ === null)  // Not laid out.
2312     return null;
2314   var margin = Mosaic.Layout.SPACING / 2;
2315   return new ImageRect(this.left_ - this.container_.scrollLeft, this.top_,
2316       this.width_, this.height_).inflate(-margin, -margin);
2320  * @return {number} X coordinate of the tile center.
2321  */
2322 Mosaic.Tile.prototype.getCenterX = function() {
2323   return this.left_ + Math.round(this.width_ / 2);