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