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