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.
6 * @param {!Element} container Content container.
7 * @param {!ErrorBanner} errorBanner Error banner.
8 * @param {!cr.ui.ArrayDataModel} dataModel Data model.
9 * @param {!cr.ui.ListSelectionModel} selectionModel Selection model.
10 * @param {!VolumeManager} volumeManager Volume manager.
11 * @param {function()} toggleMode Function to switch to the Slide mode.
16 container, errorBanner, dataModel, selectionModel, volumeManager,
18 this.mosaic_ = new Mosaic(assert(container.ownerDocument), errorBanner,
19 dataModel, selectionModel, volumeManager);
20 container.appendChild(this.mosaic_);
22 this.toggleMode_ = toggleMode;
23 this.mosaic_.addEventListener('dblclick', this.toggleMode_);
24 this.showingTimeoutID_ = null;
28 * @return {!Mosaic} The mosaic control.
30 MosaicMode.prototype.getMosaic = function() { return this.mosaic_; };
33 * @return {string} Mode name.
35 MosaicMode.prototype.getName = function() { return 'mosaic'; };
38 * @return {string} Mode title.
40 MosaicMode.prototype.getTitle = function() { return 'GALLERY_MOSAIC'; };
43 * Execute an action (this mode has no busy state).
44 * @param {function()} action Action to execute.
46 MosaicMode.prototype.executeWhenReady = function(action) { action(); };
49 * @return {boolean} Always true (no toolbar fading in this mode).
51 MosaicMode.prototype.hasActiveTool = function() { return true; };
56 * @param {!Event} event Event.
58 MosaicMode.prototype.onKeyDown = function(event) {
59 switch (util.getKeyModifiers(event) + event.keyIdentifier) {
61 if (!document.activeElement ||
62 document.activeElement.localName !== 'button') {
64 event.preventDefault();
68 this.mosaic_.onKeyDown(event);
71 ////////////////////////////////////////////////////////////////////////////////
76 * @param {!Document} document Document.
77 * @param {!ErrorBanner} errorBanner Error banner.
78 * @param {!cr.ui.ArrayDataModel} dataModel Data model.
79 * @param {!cr.ui.ListSelectionModel} selectionModel Selection model.
80 * @param {!VolumeManager} volumeManager Volume manager.
81 * @return {!Element} Mosaic element.
84 * @extends {HTMLDivElement}
85 * @suppress {checkStructDictInheritance}
87 function Mosaic(document, errorBanner, dataModel, selectionModel,
89 // This is a hack to make closure compiler recognize definitions of fields
90 // with this decorate pattern. When this constructor is called as "new
91 // Mosaic(...)", "this" should be Mosaic. In that case, this calls this
92 // constructor again with setting this as HTMLDivElement. When this condition
93 // is false, this method decorates the "this" object, and returns it.
94 if (this instanceof Mosaic) {
95 return Mosaic.call(/** @type {Mosaic} */ (document.createElement('div')),
96 document, errorBanner, dataModel, selectionModel, volumeManager);
99 this.__proto__ = Mosaic.prototype;
100 this.className = 'mosaic';
103 * @type {!cr.ui.ArrayDataModel}
106 this.dataModel_ = dataModel;
109 * @type {!cr.ui.ListSelectionModel}
112 this.selectionModel_ = selectionModel;
115 * @type {!VolumeManager}
118 this.volumeManager_ = volumeManager;
121 * @type {!ErrorBanner}
124 this.errorBanner_ = errorBanner;
127 * @type {Array.<!Mosaic.Tile>}
136 this.loadVisibleTilesSuppressed_ = false;
142 this.loadVisibleTilesScheduled_ = false;
148 this.showingTimeoutID_ = 0;
151 * @type {Mosaic.SelectionController}
154 this.selectionController_ = null;
157 * @type {Mosaic.Layout}
160 this.layoutModel_ = null;
166 this.suppressHovering_ = false;
172 this.layoutTimer_ = 0;
178 this.scrollAnimation_ = 0;
180 // Initialization is completed lazily on the first call to |init|.
186 * Inherits from HTMLDivElement.
188 Mosaic.prototype.__proto__ = HTMLDivElement.prototype;
191 * Default layout delay in ms.
195 Mosaic.LAYOUT_DELAY = 200;
198 * Smooth scroll animation duration when scrolling using keyboard or
199 * clicking on a partly visible tile. In ms.
203 Mosaic.ANIMATED_SCROLL_DURATION = 500;
206 * Initializes the mosaic element.
208 Mosaic.prototype.init = function() {
210 return; // Already initialized, nothing to do.
212 this.layoutModel_ = new Mosaic.Layout();
215 this.selectionController_ =
216 new Mosaic.SelectionController(this.selectionModel_, this.layoutModel_);
219 for (var i = 0; i !== this.dataModel_.length; i++) {
221 this.volumeManager_.getLocationInfo(this.dataModel_.item(i).getEntry());
225 assertInstanceof(this.dataModel_.item(i), Gallery.Item),
229 this.selectionModel_.selectedIndexes.forEach(function(index) {
230 this.tiles_[index].select(true);
233 this.initTiles_(this.tiles_);
235 // The listeners might be called while some tiles are still loading.
236 this.initListeners_();
240 * @return {boolean} Whether mosaic is initialized.
242 Mosaic.prototype.isInitialized = function() {
243 return !!this.tiles_;
247 * Starts listening to events.
249 * We keep listening to events even when the mosaic is hidden in order to
250 * keep the layout up to date.
254 Mosaic.prototype.initListeners_ = function() {
255 this.ownerDocument.defaultView.addEventListener(
256 'resize', this.onResize_.bind(this));
258 var mouseEventBound = this.onMouseEvent_.bind(this);
259 this.addEventListener('mousemove', mouseEventBound);
260 this.addEventListener('mousedown', mouseEventBound);
261 this.addEventListener('mouseup', mouseEventBound);
262 this.addEventListener('scroll', this.onScroll_.bind(this));
264 this.selectionModel_.addEventListener('change', this.onSelection_.bind(this));
265 this.selectionModel_.addEventListener('leadIndexChange',
266 this.onLeadChange_.bind(this));
268 this.dataModel_.addEventListener('splice', this.onSplice_.bind(this));
269 this.dataModel_.addEventListener('content', this.onContentChange_.bind(this));
273 * Smoothly scrolls the container to the specified position using
274 * f(x) = sqrt(x) speed function normalized to animation duration.
275 * @param {number} targetPosition Horizontal scroll position in pixels.
277 Mosaic.prototype.animatedScrollTo = function(targetPosition) {
278 if (this.scrollAnimation_) {
279 webkitCancelAnimationFrame(this.scrollAnimation_);
280 this.scrollAnimation_ = 0;
283 // Mouse move events are fired without touching the mouse because of scrolling
284 // the container. Therefore, these events have to be suppressed.
285 this.suppressHovering_ = true;
287 // Calculates integral area from t1 to t2 of f(x) = sqrt(x) dx.
288 var integral = function(t1, t2) {
289 return 2.0 / 3.0 * Math.pow(t2, 3.0 / 2.0) -
290 2.0 / 3.0 * Math.pow(t1, 3.0 / 2.0);
293 var delta = targetPosition - this.scrollLeft;
294 var factor = delta / integral(0, Mosaic.ANIMATED_SCROLL_DURATION);
295 var startTime = Date.now();
296 var lastPosition = 0;
297 var scrollOffset = this.scrollLeft;
299 var animationFrame = function() {
300 var position = Date.now() - startTime;
302 integral(Math.max(0, Mosaic.ANIMATED_SCROLL_DURATION - position),
303 Math.max(0, Mosaic.ANIMATED_SCROLL_DURATION - lastPosition));
304 scrollOffset += step;
306 var oldScrollLeft = this.scrollLeft;
307 var newScrollLeft = Math.round(scrollOffset);
309 if (oldScrollLeft !== newScrollLeft)
310 this.scrollLeft = newScrollLeft;
312 if (step === 0 || this.scrollLeft !== newScrollLeft) {
313 this.scrollAnimation_ = null;
314 // Release the hovering lock after a safe delay to avoid hovering
315 // a tile because of altering |this.scrollLeft|.
316 setTimeout(function() {
317 if (!this.scrollAnimation_)
318 this.suppressHovering_ = false;
321 // Continue the animation.
322 this.scrollAnimation_ = requestAnimationFrame(animationFrame);
325 lastPosition = position;
328 // Start the animation.
329 this.scrollAnimation_ = requestAnimationFrame(animationFrame);
333 * @return {Mosaic.Tile} Selected tile or undefined if no selection.
335 Mosaic.prototype.getSelectedTile = function() {
336 return this.tiles_ && this.tiles_[this.selectionModel_.selectedIndex];
340 * @param {number} index Tile index.
341 * @return {ImageRect} Tile's image rectangle.
343 Mosaic.prototype.getTileRect = function(index) {
344 var tile = this.tiles_[index];
345 return tile && tile.getImageRect();
349 * Scroll the given tile into the viewport.
350 * @param {number} index Tile index.
352 Mosaic.prototype.scrollIntoViewByIndex = function(index) {
353 var tile = this.tiles_[index];
354 if (tile) tile.scrollIntoView();
358 * Initializes multiple tiles.
360 * @param {!Array.<!Mosaic.Tile>} tiles Array of tiles.
363 Mosaic.prototype.initTiles_ = function(tiles) {
364 for (var i = 0; i < tiles.length; i++) {
372 Mosaic.prototype.reload = function() {
373 this.layoutModel_.reset_();
374 this.tiles_.forEach(function(t) { t.markUnloaded(); });
375 this.initTiles_(this.tiles_);
379 * Layouts the tiles in the order of their indices.
381 * Starts where it last stopped (at #0 the first time).
382 * Stops when all tiles are processed or when the next tile is still loading.
384 Mosaic.prototype.layout = function() {
385 if (this.layoutTimer_) {
386 clearTimeout(this.layoutTimer_);
387 this.layoutTimer_ = 0;
390 var index = this.layoutModel_.getTileCount();
391 if (index === this.tiles_.length)
392 break; // All tiles done.
393 var tile = this.tiles_[index];
394 if (!tile.isInitialized())
395 break; // Next layout will try to restart from here.
396 this.layoutModel_.add(tile, index + 1 === this.tiles_.length);
398 this.loadVisibleTiles_();
402 * Schedules the layout.
404 * @param {number=} opt_delay Delay in ms.
406 Mosaic.prototype.scheduleLayout = function(opt_delay) {
407 if (!this.layoutTimer_) {
408 this.layoutTimer_ = setTimeout(function() {
409 this.layoutTimer_ = 0;
411 }.bind(this), opt_delay || 0);
420 Mosaic.prototype.onResize_ = function() {
421 this.layoutModel_.setViewportSize(this.clientWidth, this.clientHeight -
422 (Mosaic.Layout.PADDING_TOP + Mosaic.Layout.PADDING_BOTTOM));
423 this.scheduleLayout();
427 * Mouse event handler.
429 * @param {!Event} event Event.
432 Mosaic.prototype.onMouseEvent_ = function(event) {
433 // Navigating with mouse, enable hover state.
434 if (!this.suppressHovering_)
435 this.classList.add('hover-visible');
437 if (event.type === 'mousemove')
441 for (var target = event.target;
442 target && (target !== this);
443 target = target.parentNode) {
444 if (target.classList.contains('mosaic-tile')) {
445 index = this.dataModel_.indexOf(target.getItem());
449 this.selectionController_.handlePointerDownUp(event, index);
456 Mosaic.prototype.onScroll_ = function() {
457 requestAnimationFrame(function() {
458 this.loadVisibleTiles_();
463 * Selection change handler.
465 * @param {!Event} event Event.
468 Mosaic.prototype.onSelection_ = function(event) {
469 for (var i = 0; i !== event.changes.length; i++) {
470 var change = event.changes[i];
471 var tile = this.tiles_[change.index];
472 if (tile) tile.select(change.selected);
477 * Leads item change handler.
479 * @param {!Event} event Event.
482 Mosaic.prototype.onLeadChange_ = function(event) {
483 var index = event.newValue;
485 var tile = this.tiles_[index];
486 if (tile) tile.scrollIntoView();
491 * Splice event handler.
493 * @param {!Event} event Event.
496 Mosaic.prototype.onSplice_ = function(event) {
497 var index = event.index;
498 this.layoutModel_.invalidateFromTile_(index);
500 if (event.removed.length) {
501 for (var t = 0; t !== event.removed.length; t++) {
502 // If the layout for the tile has not done yet, the parent is null.
503 // And the layout will not be done after onSplice_ because it is removed
505 if (this.tiles_[index + t].parentNode)
506 this.removeChild(this.tiles_[index + t]);
509 this.tiles_.splice(index, event.removed.length);
511 // No items left, show the banner.
512 if (this.getItemCount_() === 0)
513 this.errorBanner_.show('GALLERY_NO_IMAGES');
515 this.scheduleLayout(Mosaic.LAYOUT_DELAY);
518 if (event.added.length) {
520 for (var t = 0; t !== event.added.length; t++)
521 newTiles.push(new Mosaic.Tile(this,
522 assertInstanceof(this.dataModel_.item(index + t), Gallery.Item)));
524 this.tiles_.splice.apply(this.tiles_, [index, 0].concat(newTiles));
525 this.initTiles_(newTiles);
526 this.scheduleLayout(Mosaic.LAYOUT_DELAY);
529 if (this.tiles_.length !== this.dataModel_.length)
530 console.error('Mosaic is out of sync');
534 * Content change handler.
536 * @param {!Event} event Event.
539 Mosaic.prototype.onContentChange_ = function(event) {
544 return; // Thumbnail unchanged, nothing to do.
546 var index = this.dataModel_.indexOf(event.item);
547 if (index !== this.selectionModel_.selectedIndex)
548 console.error('Content changed for unselected item');
550 this.layoutModel_.invalidateFromTile_(index);
551 this.tiles_[index].init();
552 this.tiles_[index].unload();
553 this.tiles_[index].load(
554 Mosaic.Tile.LoadMode.HIGH_DPI,
555 this.scheduleLayout.bind(this, Mosaic.LAYOUT_DELAY));
559 * Keydown event handler.
561 * @param {!Event} event Event.
562 * @return {boolean} True if the event has been consumed.
564 Mosaic.prototype.onKeyDown = function(event) {
565 this.selectionController_.handleKeyDown(event);
566 if (event.defaultPrevented) // Navigating with keyboard, hide hover state.
567 this.classList.remove('hover-visible');
568 return event.defaultPrevented;
572 * @return {boolean} True if the mosaic zoom effect can be applied. It is
573 * too slow if there are to many images.
574 * TODO(kaznacheev): Consider unloading the images that are out of the viewport.
576 Mosaic.prototype.canZoom = function() {
577 return this.tiles_.length < 100;
583 Mosaic.prototype.show = function() {
584 // If the items are empty, just show the error message.
585 if (this.getItemCount_() === 0)
586 this.errorBanner_.show('GALLERY_NO_IMAGES');
588 var duration = ImageView.MODE_TRANSITION_DURATION;
589 if (this.canZoom()) {
590 // Fade in in parallel with the zoom effect.
591 this.setAttribute('visible', 'zooming');
593 // Mosaic is not animating but the large image is. Fade in the mosaic
594 // shortly before the large image animation is done.
597 this.showingTimeoutID_ = setTimeout(function() {
598 this.showingTimeoutID_ = 0;
599 // Make the selection visible.
600 // If the mosaic is not animated it will start fading in now.
601 this.setAttribute('visible', 'normal');
602 this.loadVisibleTiles_();
603 }.bind(this), duration);
609 Mosaic.prototype.hide = function() {
610 this.errorBanner_.clear();
612 if (this.showingTimeoutID_ !== 0) {
613 clearTimeout(this.showingTimeoutID_);
614 this.showingTimeoutID_ = 0;
616 this.removeAttribute('visible');
620 * Checks if the mosaic view is visible.
621 * @return {boolean} True if visible, false otherwise.
624 Mosaic.prototype.isVisible_ = function() {
625 return this.hasAttribute('visible');
629 * Loads visible tiles. Ignores consecutive calls. Does not reload already
633 Mosaic.prototype.loadVisibleTiles_ = function() {
634 if (this.loadVisibleTilesSuppressed_) {
635 this.loadVisibleTilesScheduled_ = true;
639 this.loadVisibleTilesSuppressed_ = true;
640 this.loadVisibleTilesScheduled_ = false;
641 setTimeout(function() {
642 this.loadVisibleTilesSuppressed_ = false;
643 if (this.loadVisibleTilesScheduled_)
644 this.loadVisibleTiles_();
647 // Tiles only in the viewport (visible).
648 var visibleRect = new ImageRect(
649 0, 0, this.clientWidth, this.clientHeight);
651 // Tiles in the viewport and also some distance on the left and right.
652 var renderableRect = new ImageRect(
655 3 * this.clientWidth,
658 // Unload tiles out of scope.
659 for (var index = 0; index < this.tiles_.length; index++) {
660 var tile = this.tiles_[index];
661 var imageRect = tile.getImageRect();
662 // Unload a thumbnail.
663 if (imageRect && !imageRect.intersects(renderableRect))
667 // Load the visible tiles first.
668 var allVisibleLoaded = true;
669 // Show high-dpi only when the mosaic view is visible.
670 var loadMode = this.isVisible_() ? Mosaic.Tile.LoadMode.HIGH_DPI :
671 Mosaic.Tile.LoadMode.LOW_DPI;
672 for (var index = 0; index < this.tiles_.length; index++) {
673 var tile = this.tiles_[index];
674 var imageRect = tile.getImageRect();
676 if (!tile.isLoading(loadMode) && !tile.isLoaded(loadMode) && imageRect &&
677 imageRect.intersects(visibleRect)) {
678 tile.load(loadMode, function() {});
679 allVisibleLoaded = false;
683 // Load also another, nearby, if the visible has been already loaded.
684 if (allVisibleLoaded) {
685 for (var index = 0; index < this.tiles_.length; index++) {
686 var tile = this.tiles_[index];
687 var imageRect = tile.getImageRect();
689 if (!tile.isLoading() && !tile.isLoaded() && imageRect &&
690 imageRect.intersects(renderableRect)) {
691 tile.load(Mosaic.Tile.LoadMode.LOW_DPI, function() {});
698 * Applies reset the zoom transform.
700 * @param {ImageRect} tileRect Tile rectangle. Reset the transform if null.
701 * @param {ImageRect} imageRect Large image rectangle. Reset the transform if
703 * @param {boolean=} opt_instant True of the transition should be instant.
705 Mosaic.prototype.transform = function(tileRect, imageRect, opt_instant) {
707 this.style.webkitTransitionDuration = '0';
709 this.style.webkitTransitionDuration =
710 ImageView.MODE_TRANSITION_DURATION + 'ms';
713 if (this.canZoom() && tileRect && imageRect) {
714 var scaleX = imageRect.width / tileRect.width;
715 var scaleY = imageRect.height / tileRect.height;
716 var shiftX = (imageRect.left + imageRect.width / 2) -
717 (tileRect.left + tileRect.width / 2);
718 var shiftY = (imageRect.top + imageRect.height / 2) -
719 (tileRect.top + tileRect.height / 2);
720 this.style.webkitTransform =
721 'translate(' + shiftX * scaleX + 'px, ' + shiftY * scaleY + 'px)' +
722 'scaleX(' + scaleX + ') scaleY(' + scaleY + ')';
724 this.style.webkitTransform = '';
729 * @return {number} Item count
732 Mosaic.prototype.getItemCount_ = function() {
733 return this.dataModel_.length;
736 ////////////////////////////////////////////////////////////////////////////////
739 * Creates a selection controller that is to be used with grid.
740 * @param {!cr.ui.ListSelectionModel} selectionModel The selection model to
742 * @param {!Mosaic.Layout} layoutModel The layout model to use.
745 * @extends {cr.ui.ListSelectionController}
746 * @suppress {checkStructDictInheritance}
748 Mosaic.SelectionController = function(selectionModel, layoutModel) {
749 cr.ui.ListSelectionController.call(this, selectionModel);
750 this.layoutModel_ = layoutModel;
754 * Extends cr.ui.ListSelectionController.
756 Mosaic.SelectionController.prototype.__proto__ =
757 cr.ui.ListSelectionController.prototype;
760 Mosaic.SelectionController.prototype.getLastIndex = function() {
761 return this.layoutModel_.getLaidOutTileCount() - 1;
765 Mosaic.SelectionController.prototype.getIndexBefore = function(index) {
766 return this.layoutModel_.getHorizontalAdjacentIndex(index, -1);
770 Mosaic.SelectionController.prototype.getIndexAfter = function(index) {
771 return this.layoutModel_.getHorizontalAdjacentIndex(index, 1);
775 Mosaic.SelectionController.prototype.getIndexAbove = function(index) {
776 return this.layoutModel_.getVerticalAdjacentIndex(index, -1);
780 Mosaic.SelectionController.prototype.getIndexBelow = function(index) {
781 return this.layoutModel_.getVerticalAdjacentIndex(index, 1);
784 ////////////////////////////////////////////////////////////////////////////////
789 * @param {string=} opt_mode Layout mode.
790 * @param {Mosaic.Density=} opt_maxDensity Layout density.
794 Mosaic.Layout = function(opt_mode, opt_maxDensity) {
795 this.mode_ = opt_mode || Mosaic.Layout.Mode.TENTATIVE;
796 this.maxDensity_ = opt_maxDensity || Mosaic.Density.createHighest();
799 * @type {!Array.<!Mosaic.Column>}
805 * @type {Mosaic.Column}
808 this.newColumn_ = null;
814 this.viewportWidth_ = 0;
820 this.viewportHeight_ = 0;
823 * @type {Mosaic.Density}
826 this.density_ = null;
832 * Blank space at the top of the mosaic element. We do not do that in CSS
833 * to make transition effects easier.
837 Mosaic.Layout.PADDING_TOP = 50;
840 * Blank space at the bottom of the mosaic element.
844 Mosaic.Layout.PADDING_BOTTOM = 50;
847 * Horizontal and vertical spacing between images. Should be kept in sync
848 * with the style of .mosaic-item in gallery.css (= 2 * ( 4 + 1))
852 Mosaic.Layout.SPACING = 10;
855 * Margin for scrolling using keyboard. Distance between a selected tile
860 Mosaic.Layout.SCROLL_MARGIN = 30;
866 Mosaic.Layout.Mode = {
867 // Commit to DOM immediately.
869 // Do not commit layout to DOM until it is complete or the viewport
871 TENTATIVE: 'tentative',
872 // Never commit layout to DOM.
881 Mosaic.Layout.prototype.reset_ = function() {
883 this.newColumn_ = null;
884 this.density_ = Mosaic.Density.createLowest();
885 if (this.mode_ !== Mosaic.Layout.Mode.DRY_RUN) // DRY_RUN is sticky.
886 this.mode_ = Mosaic.Layout.Mode.TENTATIVE;
890 * @param {number} width Viewport width.
891 * @param {number} height Viewport height.
893 Mosaic.Layout.prototype.setViewportSize = function(width, height) {
894 this.viewportWidth_ = width;
895 this.viewportHeight_ = height;
900 * @return {number} Total width of the layout.
902 Mosaic.Layout.prototype.getWidth = function() {
903 var lastColumn = this.getLastColumn_();
904 return lastColumn ? lastColumn.getRight() : 0;
908 * @return {number} Total height of the layout.
910 Mosaic.Layout.prototype.getHeight = function() {
911 var firstColumn = this.columns_[0];
912 return firstColumn ? firstColumn.getHeight() : 0;
916 * @return {!Array.<!Mosaic.Tile>} All tiles in the layout.
918 Mosaic.Layout.prototype.getTiles = function() {
919 return Array.prototype.concat.apply([],
920 this.columns_.map(function(c) { return c.getTiles(); }));
924 * @return {number} Total number of tiles added to the layout.
926 Mosaic.Layout.prototype.getTileCount = function() {
927 return this.getLaidOutTileCount() +
928 (this.newColumn_ ? this.newColumn_.getTileCount() : 0);
932 * @return {Mosaic.Column} The last column or null for empty layout.
935 Mosaic.Layout.prototype.getLastColumn_ = function() {
936 return this.columns_.length ? this.columns_[this.columns_.length - 1] : null;
940 * @return {number} Total number of tiles in completed columns.
942 Mosaic.Layout.prototype.getLaidOutTileCount = function() {
943 var lastColumn = this.getLastColumn_();
944 return lastColumn ? lastColumn.getNextTileIndex() : 0;
948 * Adds a tile to the layout.
950 * @param {!Mosaic.Tile} tile The tile to be added.
951 * @param {boolean} isLast True if this tile is the last.
953 Mosaic.Layout.prototype.add = function(tile, isLast) {
954 var layoutQueue = [tile];
956 // There are two levels of backtracking in the layout algorithm.
957 // |Mosaic.Layout.density_| tracks the state of the 'global' backtracking
958 // which aims to use as much of the viewport space as possible.
959 // It starts with the lowest density and increases it until the layout
960 // fits into the viewport. If it does not fit even at the highest density,
961 // the layout continues with the highest density.
963 // |Mosaic.Column.density_| tracks the state of the 'local' backtracking
964 // which aims to avoid producing unnaturally looking columns.
965 // It starts with the current global density and decreases it until the column
968 while (layoutQueue.length) {
969 if (!this.newColumn_) {
970 var lastColumn = this.getLastColumn_();
971 this.newColumn_ = new Mosaic.Column(
972 this.columns_.length,
973 lastColumn ? lastColumn.getNextRowIndex() : 0,
974 lastColumn ? lastColumn.getNextTileIndex() : 0,
975 lastColumn ? lastColumn.getRight() : 0,
976 this.viewportHeight_,
977 this.density_.clone());
980 this.newColumn_.add(layoutQueue.shift());
982 var isFinalColumn = isLast && !layoutQueue.length;
984 if (!this.newColumn_.prepareLayout(isFinalColumn))
985 continue; // Column is incomplete.
987 if (this.newColumn_.isSuboptimal()) {
988 layoutQueue = this.newColumn_.getTiles().concat(layoutQueue);
989 this.newColumn_.retryWithLowerDensity();
993 this.columns_.push(this.newColumn_);
994 this.newColumn_ = null;
996 if (this.mode_ === Mosaic.Layout.Mode.FINAL && isFinalColumn) {
1001 if (this.getWidth() > this.viewportWidth_) {
1002 // Viewport completely filled.
1003 if (this.density_.equals(this.maxDensity_)) {
1004 // Max density reached, commit if tentative, just continue if dry run.
1005 if (this.mode_ === Mosaic.Layout.Mode.TENTATIVE)
1010 // Rollback the entire layout, retry with higher density.
1011 layoutQueue = this.getTiles().concat(layoutQueue);
1013 this.density_.increase();
1017 if (isFinalColumn && this.mode_ === Mosaic.Layout.Mode.TENTATIVE) {
1018 // The complete tentative layout fits into the viewport.
1019 var stretched = this.findHorizontalLayout_();
1021 this.columns_ = stretched.columns_;
1022 // Center the layout in the viewport and commit.
1023 this.commit_((this.viewportWidth_ - this.getWidth()) / 2,
1024 (this.viewportHeight_ - this.getHeight()) / 2);
1030 * Commits the tentative layout.
1032 * @param {number=} opt_offsetX Horizontal offset.
1033 * @param {number=} opt_offsetY Vertical offset.
1036 Mosaic.Layout.prototype.commit_ = function(opt_offsetX, opt_offsetY) {
1037 for (var i = 0; i !== this.columns_.length; i++) {
1038 this.columns_[i].layout(opt_offsetX, opt_offsetY);
1040 this.mode_ = Mosaic.Layout.Mode.FINAL;
1044 * Finds the most horizontally stretched layout built from the same tiles.
1046 * The main layout algorithm fills the entire available viewport height.
1047 * If there is too few tiles this results in a layout that is unnaturally
1048 * stretched in the vertical direction.
1050 * This method tries a number of smaller heights and returns the most
1051 * horizontally stretched layout that still fits into the viewport.
1053 * @return {Mosaic.Layout} A horizontally stretched layout.
1056 Mosaic.Layout.prototype.findHorizontalLayout_ = function() {
1057 // If the layout aspect ratio is not dramatically different from
1058 // the viewport aspect ratio then there is no need to optimize.
1059 if (this.getWidth() / this.getHeight() >
1060 this.viewportWidth_ / this.viewportHeight_ * 0.9)
1063 var tiles = this.getTiles();
1064 if (tiles.length === 1)
1065 return null; // Single tile layout is always the same.
1067 var tileHeights = tiles.map(function(t) { return t.getMaxContentHeight(); });
1068 var minTileHeight = Math.min.apply(null, tileHeights);
1070 for (var h = minTileHeight; h < this.viewportHeight_; h += minTileHeight) {
1071 var layout = new Mosaic.Layout(
1072 Mosaic.Layout.Mode.DRY_RUN, this.density_.clone());
1073 layout.setViewportSize(this.viewportWidth_, h);
1074 for (var t = 0; t !== tiles.length; t++)
1075 layout.add(tiles[t], t + 1 === tiles.length);
1077 if (layout.getWidth() <= this.viewportWidth_)
1085 * Invalidates the layout after the given tile was modified (added, deleted or
1086 * changed dimensions).
1088 * @param {number} index Tile index.
1091 Mosaic.Layout.prototype.invalidateFromTile_ = function(index) {
1092 var columnIndex = this.getColumnIndexByTile_(index);
1093 if (columnIndex < 0)
1094 return; // Index not in the layout, probably already invalidated.
1096 if (this.columns_[columnIndex].getLeft() >= this.viewportWidth_) {
1097 // The columns to the right cover the entire viewport width, so there is no
1098 // chance that the modified layout would fit into the viewport.
1099 // No point in restarting the entire layout, keep the columns to the right.
1100 console.assert(this.mode_ === Mosaic.Layout.Mode.FINAL,
1101 'Expected FINAL layout mode');
1102 this.columns_ = this.columns_.slice(0, columnIndex);
1103 this.newColumn_ = null;
1105 // There is a chance that the modified layout would fit into the viewport.
1107 this.mode_ = Mosaic.Layout.Mode.TENTATIVE;
1112 * Gets the index of the tile to the left or to the right from the given tile.
1114 * @param {number} index Tile index.
1115 * @param {number} direction -1 for left, 1 for right.
1116 * @return {number} Adjacent tile index.
1118 Mosaic.Layout.prototype.getHorizontalAdjacentIndex = function(
1120 var column = this.getColumnIndexByTile_(index);
1122 console.error('Cannot find column for tile #' + index);
1126 var row = this.columns_[column].getRowByTileIndex(index);
1128 console.error('Cannot find row for tile #' + index);
1132 var sameRowNeighbourIndex = index + direction;
1133 if (row.hasTile(sameRowNeighbourIndex))
1134 return sameRowNeighbourIndex;
1136 var adjacentColumn = column + direction;
1137 if (adjacentColumn < 0 || adjacentColumn === this.columns_.length)
1140 return this.columns_[adjacentColumn].
1141 getEdgeTileIndex_(row.getCenterY(), -direction);
1145 * Gets the index of the tile to the top or to the bottom from the given tile.
1147 * @param {number} index Tile index.
1148 * @param {number} direction -1 for above, 1 for below.
1149 * @return {number} Adjacent tile index.
1151 Mosaic.Layout.prototype.getVerticalAdjacentIndex = function(
1153 var column = this.getColumnIndexByTile_(index);
1155 console.error('Cannot find column for tile #' + index);
1159 var row = this.columns_[column].getRowByTileIndex(index);
1161 console.error('Cannot find row for tile #' + index);
1165 // Find the first item in the next row, or the last item in the previous row.
1166 var adjacentRowNeighbourIndex =
1167 row.getEdgeTileIndex_(direction) + direction;
1169 if (adjacentRowNeighbourIndex < 0 ||
1170 adjacentRowNeighbourIndex > this.getTileCount() - 1)
1173 if (!this.columns_[column].hasTile(adjacentRowNeighbourIndex)) {
1174 // It is not in the current column, so return it.
1175 return adjacentRowNeighbourIndex;
1177 // It is in the current column, so we have to find optically the closest
1178 // tile in the adjacent row.
1179 var adjacentRow = this.columns_[column].getRowByTileIndex(
1180 adjacentRowNeighbourIndex);
1181 var previousTileCenterX = row.getTileByIndex(index).getCenterX();
1183 // Find the closest one.
1184 var closestIndex = -1;
1185 var closestDistance;
1186 var adjacentRowTiles = adjacentRow.getTiles();
1187 for (var t = 0; t !== adjacentRowTiles.length; t++) {
1189 Math.abs(adjacentRowTiles[t].getCenterX() - previousTileCenterX);
1190 if (closestIndex === -1 || distance < closestDistance) {
1191 closestIndex = adjacentRow.getEdgeTileIndex_(-1) + t;
1192 closestDistance = distance;
1195 return closestIndex;
1200 * @param {number} index Tile index.
1201 * @return {number} Index of the column containing the given tile.
1204 Mosaic.Layout.prototype.getColumnIndexByTile_ = function(index) {
1205 for (var c = 0; c !== this.columns_.length; c++) {
1206 if (this.columns_[c].hasTile(index))
1213 * Scales the given array of size values to satisfy 3 conditions:
1214 * 1. The new sizes must be integer.
1215 * 2. The new sizes must sum up to the given |total| value.
1216 * 3. The relative proportions of the sizes should be as close to the original
1219 * @param {!Array.<number>} sizes Array of sizes.
1220 * @param {number} newTotal New total size.
1222 Mosaic.Layout.rescaleSizesToNewTotal = function(sizes, newTotal) {
1225 var partialTotals = [0];
1226 for (var i = 0; i !== sizes.length; i++) {
1228 partialTotals.push(total);
1231 var scale = newTotal / total;
1233 for (i = 0; i !== sizes.length; i++) {
1234 sizes[i] = Math.round(partialTotals[i + 1] * scale) -
1235 Math.round(partialTotals[i] * scale);
1239 ////////////////////////////////////////////////////////////////////////////////
1242 * Representation of the layout density.
1244 * @param {number} horizontal Horizontal density, number tiles per row.
1245 * @param {number} vertical Vertical density, frequency of rows forced to
1246 * contain a single tile.
1250 Mosaic.Density = function(horizontal, vertical) {
1251 this.horizontal = horizontal;
1252 this.vertical = vertical;
1256 * Minimal horizontal density (tiles per row).
1260 Mosaic.Density.MIN_HORIZONTAL = 1;
1263 * Minimal horizontal density (tiles per row).
1267 Mosaic.Density.MAX_HORIZONTAL = 3;
1270 * Minimal vertical density: force 1 out of 2 rows to containt a single tile.
1274 Mosaic.Density.MIN_VERTICAL = 2;
1277 * Maximal vertical density: force 1 out of 3 rows to containt a single tile.
1281 Mosaic.Density.MAX_VERTICAL = 3;
1284 * @return {!Mosaic.Density} Lowest density.
1286 Mosaic.Density.createLowest = function() {
1287 return new Mosaic.Density(
1288 Mosaic.Density.MIN_HORIZONTAL,
1289 Mosaic.Density.MIN_VERTICAL /* ignored when horizontal is at min */);
1293 * @return {!Mosaic.Density} Highest density.
1295 Mosaic.Density.createHighest = function() {
1296 return new Mosaic.Density(
1297 Mosaic.Density.MAX_HORIZONTAL,
1298 Mosaic.Density.MAX_VERTICAL);
1302 * @return {!Mosaic.Density} A clone of this density object.
1304 Mosaic.Density.prototype.clone = function() {
1305 return new Mosaic.Density(this.horizontal, this.vertical);
1309 * @param {!Mosaic.Density} that The other object.
1310 * @return {boolean} True if equal.
1312 Mosaic.Density.prototype.equals = function(that) {
1313 return this.horizontal === that.horizontal &&
1314 this.vertical === that.vertical;
1318 * Increases the density to the next level.
1320 Mosaic.Density.prototype.increase = function() {
1321 if (this.horizontal === Mosaic.Density.MIN_HORIZONTAL ||
1322 this.vertical === Mosaic.Density.MAX_VERTICAL) {
1323 console.assert(this.horizontal < Mosaic.Density.MAX_HORIZONTAL);
1325 this.vertical = Mosaic.Density.MIN_VERTICAL;
1332 * Decreases horizontal density.
1334 Mosaic.Density.prototype.decreaseHorizontal = function() {
1335 console.assert(this.horizontal > Mosaic.Density.MIN_HORIZONTAL);
1340 * @param {number} tileCount Number of tiles in the row.
1341 * @param {number} rowIndex Global row index.
1342 * @return {boolean} True if the row is complete.
1344 Mosaic.Density.prototype.isRowComplete = function(tileCount, rowIndex) {
1345 return (tileCount === this.horizontal) || (rowIndex % this.vertical) === 0;
1348 ////////////////////////////////////////////////////////////////////////////////
1351 * A column in a mosaic layout. Contains rows.
1353 * @param {number} index Column index.
1354 * @param {number} firstRowIndex Global row index.
1355 * @param {number} firstTileIndex Index of the first tile in the column.
1356 * @param {number} left Left edge coordinate.
1357 * @param {number} maxHeight Maximum height.
1358 * @param {!Mosaic.Density} density Layout density.
1362 Mosaic.Column = function(index, firstRowIndex, firstTileIndex, left, maxHeight,
1364 this.index_ = index;
1365 this.firstRowIndex_ = firstRowIndex;
1366 this.firstTileIndex_ = firstTileIndex;
1368 this.maxHeight_ = maxHeight;
1369 this.density_ = density;
1378 * @type {!Array.<!Mosaic.Tile>}
1384 * @type {!Array.<!Mosaic.Row>}
1390 * @type {Mosaic.Row}
1393 this.newRow_ = null;
1396 * @type {!Array.<number>}
1399 this.rowHeights_ = [];
1411 * Resets the layout.
1414 Mosaic.Column.prototype.reset_ = function() {
1417 this.newRow_ = null;
1421 * @return {number} Number of tiles in the column.
1423 Mosaic.Column.prototype.getTileCount = function() { return this.tiles_.length };
1426 * @return {number} Index of the last tile + 1.
1428 Mosaic.Column.prototype.getNextTileIndex = function() {
1429 return this.firstTileIndex_ + this.getTileCount();
1433 * @return {number} Global index of the last row + 1.
1435 Mosaic.Column.prototype.getNextRowIndex = function() {
1436 return this.firstRowIndex_ + this.rows_.length;
1440 * @return {!Array.<!Mosaic.Tile>} Array of tiles in the column.
1442 Mosaic.Column.prototype.getTiles = function() { return this.tiles_ };
1445 * @param {number} index Tile index.
1446 * @return {boolean} True if this column contains the tile with the given index.
1448 Mosaic.Column.prototype.hasTile = function(index) {
1449 return this.firstTileIndex_ <= index &&
1450 index < (this.firstTileIndex_ + this.getTileCount());
1454 * @param {number} y Y coordinate.
1455 * @param {number} direction -1 for left, 1 for right.
1456 * @return {number} Index of the tile lying on the edge of the column at the
1457 * given y coordinate.
1460 Mosaic.Column.prototype.getEdgeTileIndex_ = function(y, direction) {
1461 for (var r = 0; r < this.rows_.length; r++) {
1462 if (this.rows_[r].coversY(y))
1463 return this.rows_[r].getEdgeTileIndex_(direction);
1469 * @param {number} index Tile index.
1470 * @return {Mosaic.Row} The row containing the tile with a given index.
1472 Mosaic.Column.prototype.getRowByTileIndex = function(index) {
1473 for (var r = 0; r !== this.rows_.length; r++) {
1474 if (this.rows_[r].hasTile(index))
1475 return this.rows_[r];
1481 * Adds a tile to the column.
1483 * @param {!Mosaic.Tile} tile The tile to add.
1485 Mosaic.Column.prototype.add = function(tile) {
1486 var rowIndex = this.getNextRowIndex();
1489 this.newRow_ = new Mosaic.Row(this.getNextTileIndex());
1491 this.tiles_.push(tile);
1492 this.newRow_.add(tile);
1494 if (this.density_.isRowComplete(this.newRow_.getTileCount(), rowIndex)) {
1495 this.rows_.push(this.newRow_);
1496 this.newRow_ = null;
1501 * Prepares the column layout.
1503 * @param {boolean=} opt_force True if the layout must be performed even for an
1504 * incomplete column.
1505 * @return {boolean} True if the layout was performed.
1507 Mosaic.Column.prototype.prepareLayout = function(opt_force) {
1508 if (opt_force && this.newRow_) {
1509 this.rows_.push(this.newRow_);
1510 this.newRow_ = null;
1513 if (this.rows_.length === 0)
1516 this.width_ = Math.min.apply(
1517 null, this.rows_.map(function(row) { return row.getMaxWidth() }));
1521 this.rowHeights_ = [];
1522 for (var r = 0; r !== this.rows_.length; r++) {
1523 var rowHeight = this.rows_[r].getHeightForWidth(this.width_);
1524 this.height_ += rowHeight;
1525 this.rowHeights_.push(rowHeight);
1528 var overflow = this.height_ / this.maxHeight_;
1529 if (!opt_force && (overflow < 1))
1533 // Scale down the column width and height.
1534 this.width_ = Math.round(this.width_ / overflow);
1535 this.height_ = this.maxHeight_;
1536 Mosaic.Layout.rescaleSizesToNewTotal(this.rowHeights_, this.maxHeight_);
1543 * Retries the column layout with less tiles per row.
1545 Mosaic.Column.prototype.retryWithLowerDensity = function() {
1546 this.density_.decreaseHorizontal();
1551 * @return {number} Column left edge coordinate.
1553 Mosaic.Column.prototype.getLeft = function() { return this.left_ };
1556 * @return {number} Column right edge coordinate after the layout.
1558 Mosaic.Column.prototype.getRight = function() {
1559 return this.left_ + this.width_;
1563 * @return {number} Column height after the layout.
1565 Mosaic.Column.prototype.getHeight = function() { return this.height_ };
1568 * Performs the column layout.
1569 * @param {number=} opt_offsetX Horizontal offset.
1570 * @param {number=} opt_offsetY Vertical offset.
1572 Mosaic.Column.prototype.layout = function(opt_offsetX, opt_offsetY) {
1573 opt_offsetX = opt_offsetX || 0;
1574 opt_offsetY = opt_offsetY || 0;
1575 var rowTop = Mosaic.Layout.PADDING_TOP;
1576 for (var r = 0; r !== this.rows_.length; r++) {
1577 this.rows_[r].layout(
1578 opt_offsetX + this.left_,
1579 opt_offsetY + rowTop,
1581 this.rowHeights_[r]);
1582 rowTop += this.rowHeights_[r];
1587 * Checks if the column layout is too ugly to be displayed.
1589 * @return {boolean} True if the layout is suboptimal.
1591 Mosaic.Column.prototype.isSuboptimal = function() {
1593 this.rows_.map(function(row) { return row.getTileCount() });
1595 var maxTileCount = Math.max.apply(null, tileCounts);
1596 if (maxTileCount === 1)
1597 return false; // Every row has exactly 1 tile, as optimal as it gets.
1600 this.tiles_.map(function(tile) { return tile.getMaxContentHeight() });
1602 // Ugly layout #1: all images are small and some are one the same row.
1603 var allSmall = Math.max.apply(null, sizes) <= Mosaic.Tile.SMALL_IMAGE_SIZE;
1607 // Ugly layout #2: all images are large and none occupies an entire row.
1608 var allLarge = Math.min.apply(null, sizes) > Mosaic.Tile.SMALL_IMAGE_SIZE;
1609 var allCombined = Math.min.apply(null, tileCounts) !== 1;
1610 if (allLarge && allCombined)
1613 // Ugly layout #3: some rows have too many tiles for the resulting width.
1614 if (this.width_ / maxTileCount < 100)
1620 ////////////////////////////////////////////////////////////////////////////////
1623 * A row in a mosaic layout. Contains tiles.
1625 * @param {number} firstTileIndex Index of the first tile in the row.
1629 Mosaic.Row = function(firstTileIndex) {
1630 this.firstTileIndex_ = firstTileIndex;
1647 * @param {!Mosaic.Tile} tile The tile to add.
1649 Mosaic.Row.prototype.add = function(tile) {
1650 console.assert(this.getTileCount() < Mosaic.Density.MAX_HORIZONTAL);
1651 this.tiles_.push(tile);
1655 * @return {!Array.<!Mosaic.Tile>} Array of tiles in the row.
1657 Mosaic.Row.prototype.getTiles = function() { return this.tiles_ };
1660 * Gets a tile by index.
1661 * @param {number} index Tile index.
1662 * @return {Mosaic.Tile} Requested tile or null if not found.
1664 Mosaic.Row.prototype.getTileByIndex = function(index) {
1665 if (!this.hasTile(index))
1667 return this.tiles_[index - this.firstTileIndex_];
1672 * @return {number} Number of tiles in the row.
1674 Mosaic.Row.prototype.getTileCount = function() { return this.tiles_.length };
1677 * @param {number} index Tile index.
1678 * @return {boolean} True if this row contains the tile with the given index.
1680 Mosaic.Row.prototype.hasTile = function(index) {
1681 return this.firstTileIndex_ <= index &&
1682 index < (this.firstTileIndex_ + this.tiles_.length);
1686 * @param {number} y Y coordinate.
1687 * @return {boolean} True if this row covers the given Y coordinate.
1689 Mosaic.Row.prototype.coversY = function(y) {
1690 return this.top_ <= y && y < (this.top_ + this.height_);
1694 * @return {number} Y coordinate of the tile center.
1696 Mosaic.Row.prototype.getCenterY = function() {
1697 return this.top_ + Math.round(this.height_ / 2);
1701 * Gets the first or the last tile.
1703 * @param {number} direction -1 for the first tile, 1 for the last tile.
1704 * @return {number} Tile index.
1707 Mosaic.Row.prototype.getEdgeTileIndex_ = function(direction) {
1709 return this.firstTileIndex_;
1711 return this.firstTileIndex_ + this.getTileCount() - 1;
1715 * @return {number} Aspect ration of the combined content box of this row.
1718 Mosaic.Row.prototype.getTotalContentAspectRatio_ = function() {
1720 for (var t = 0; t !== this.tiles_.length; t++)
1721 sum += this.tiles_[t].getAspectRatio();
1726 * @return {number} Total horizontal spacing in this row. This includes
1727 * the spacing between the tiles and both left and right margins.
1731 Mosaic.Row.prototype.getTotalHorizontalSpacing_ = function() {
1732 return Mosaic.Layout.SPACING * this.getTileCount();
1736 * @return {number} Maximum width that this row may have without overscaling
1739 Mosaic.Row.prototype.getMaxWidth = function() {
1740 var contentHeight = Math.min.apply(null,
1741 this.tiles_.map(function(tile) { return tile.getMaxContentHeight() }));
1744 Math.round(contentHeight * this.getTotalContentAspectRatio_());
1745 return contentWidth + this.getTotalHorizontalSpacing_();
1749 * Computes the height that best fits the supplied row width given
1750 * aspect ratios of the tiles in this row.
1752 * @param {number} width Row width.
1753 * @return {number} Height.
1755 Mosaic.Row.prototype.getHeightForWidth = function(width) {
1756 var contentWidth = width - this.getTotalHorizontalSpacing_();
1758 Math.round(contentWidth / this.getTotalContentAspectRatio_());
1759 return contentHeight + Mosaic.Layout.SPACING;
1763 * Positions the row in the mosaic.
1765 * @param {number} left Left position.
1766 * @param {number} top Top position.
1767 * @param {number} width Width.
1768 * @param {number} height Height.
1770 Mosaic.Row.prototype.layout = function(left, top, width, height) {
1772 this.height_ = height;
1774 var contentWidth = width - this.getTotalHorizontalSpacing_();
1775 var contentHeight = height - Mosaic.Layout.SPACING;
1777 var tileContentWidth = this.tiles_.map(
1778 function(tile) { return tile.getAspectRatio() });
1780 Mosaic.Layout.rescaleSizesToNewTotal(tileContentWidth, contentWidth);
1782 var tileLeft = left;
1783 for (var t = 0; t !== this.tiles_.length; t++) {
1784 var tileWidth = tileContentWidth[t] + Mosaic.Layout.SPACING;
1785 this.tiles_[t].layout(tileLeft, top, tileWidth, height);
1786 tileLeft += tileWidth;
1790 ////////////////////////////////////////////////////////////////////////////////
1793 * A single tile of the image mosaic.
1795 * @param {!Element} container Container element.
1796 * @param {!Gallery.Item} item Gallery item associated with this tile.
1797 * @param {EntryLocation=} opt_locationInfo Location information for the tile.
1798 * @return {!Element} The new tile element.
1800 * @extends {HTMLDivElement}
1802 * @suppress {checkStructDictInheritance}
1804 Mosaic.Tile = function(container, item, opt_locationInfo) {
1805 // This is a hack to make closure compiler recognize definitions of fields
1806 // with this decorate pattern. When this constructor is called as "new
1807 // Mosaic.Tile(...)", "this" should be Mosaic.Tile. In that case, this calls
1808 // this constructor again with setting this as HTMLDivElement. When this
1809 // condition is false, this method decorates the "this" object, and returns
1811 if (this instanceof Mosaic.Tile) {
1812 return Mosaic.Tile.call(
1813 /** @type {Mosaic.Tile} */ (document.createElement('div')),
1814 container, item, opt_locationInfo);
1817 this.__proto__ = Mosaic.Tile.prototype;
1818 this.className = 'mosaic-tile';
1824 this.container_ = container;
1827 * @type {!Gallery.Item}
1836 this.hidpiEmbedded_ = !!opt_locationInfo && opt_locationInfo.isDriveBased;
1842 this.left_ = null; // Mark as not laid out.
1866 this.maxContentHeight_ = 0;
1872 this.aspectRatio_ = 0;
1875 * @type {ThumbnailLoader}
1878 this.thumbnailPreloader_ = null;
1881 * @type {ThumbnailLoader}
1884 this.thumbnailLoader_ = null;
1890 this.imagePreloaded_ = false;
1896 this.imageLoaded_ = false;
1902 this.imagePreloading_ = false;
1908 this.imageLoading_ = false;
1911 * @type {HTMLDivElement}
1914 this.wrapper_ = null;
1920 * Load mode for the tile's image.
1923 Mosaic.Tile.LoadMode = {
1929 * Inherit from HTMLDivElement.
1931 Mosaic.Tile.prototype.__proto__ = HTMLDivElement.prototype;
1934 * Minimum tile content size.
1938 Mosaic.Tile.MIN_CONTENT_SIZE = 64;
1941 * Maximum tile content size.
1945 Mosaic.Tile.MAX_CONTENT_SIZE = 512;
1948 * Default size for a tile with no thumbnail image.
1952 Mosaic.Tile.GENERIC_ICON_SIZE = 128;
1955 * Max size of an image considered to be 'small'.
1956 * Small images are laid out slightly differently.
1960 Mosaic.Tile.SMALL_IMAGE_SIZE = 160;
1963 * @return {!Gallery.Item} The Gallery item.
1965 Mosaic.Tile.prototype.getItem = function() { return this.item_; };
1968 * @return {number} Maximum content height that this tile can have.
1970 Mosaic.Tile.prototype.getMaxContentHeight = function() {
1971 return this.maxContentHeight_;
1975 * @return {number} The aspect ratio of the tile image.
1977 Mosaic.Tile.prototype.getAspectRatio = function() { return this.aspectRatio_; };
1980 * @return {boolean} True if the tile is initialized.
1982 Mosaic.Tile.prototype.isInitialized = function() {
1983 return !!this.maxContentHeight_;
1987 * Checks whether the image of specified (or better resolution) has been loaded.
1989 * @param {Mosaic.Tile.LoadMode=} opt_loadMode Loading mode, default: LOW_DPI.
1990 * @return {boolean} True if the tile is loaded with the specified dpi or
1993 Mosaic.Tile.prototype.isLoaded = function(opt_loadMode) {
1994 var loadMode = opt_loadMode || Mosaic.Tile.LoadMode.LOW_DPI;
1996 case Mosaic.Tile.LoadMode.LOW_DPI:
1997 if (this.imagePreloaded_ || this.imageLoaded_)
2000 case Mosaic.Tile.LoadMode.HIGH_DPI:
2001 if (this.imageLoaded_)
2009 * Checks whether the image of specified (or better resolution) is being loaded.
2011 * @param {Mosaic.Tile.LoadMode=} opt_loadMode Loading mode, default: LOW_DPI.
2012 * @return {boolean} True if the tile is being loaded with the specified dpi or
2015 Mosaic.Tile.prototype.isLoading = function(opt_loadMode) {
2016 var loadMode = opt_loadMode || Mosaic.Tile.LoadMode.LOW_DPI;
2018 case Mosaic.Tile.LoadMode.LOW_DPI:
2019 if (this.imagePreloading_ || this.imageLoading_)
2022 case Mosaic.Tile.LoadMode.HIGH_DPI:
2023 if (this.imageLoading_)
2031 * Marks the tile as not loaded to prevent it from participating in the layout.
2033 Mosaic.Tile.prototype.markUnloaded = function() {
2034 this.maxContentHeight_ = 0;
2035 if (this.thumbnailLoader_) {
2036 this.thumbnailLoader_.cancel();
2037 this.imagePreloaded_ = false;
2038 this.imagePreloading_ = false;
2039 this.imageLoaded_ = false;
2040 this.imageLoading_ = false;
2045 * Initializes the thumbnail in the tile. Does not load an image, but sets
2046 * target dimensions using metadata.
2048 Mosaic.Tile.prototype.init = function() {
2049 var metadata = this.getItem().getMetadata();
2050 this.markUnloaded();
2051 this.left_ = null; // Mark as not laid out.
2053 // Set higher priority for the selected elements to load them first.
2054 var priority = this.getAttribute('selected') ? 2 : 3;
2056 // Use embedded thumbnails on Drive, since they have higher resolution.
2057 this.thumbnailLoader_ = new ThumbnailLoader(
2058 this.getItem().getEntry(),
2059 ThumbnailLoader.LoaderType.CANVAS,
2061 undefined, // Media type.
2062 this.hidpiEmbedded_ ?
2063 ThumbnailLoader.UseEmbedded.USE_EMBEDDED :
2064 ThumbnailLoader.UseEmbedded.NO_EMBEDDED,
2067 // If no hidpi embedded thumbnail available, then use the low resolution
2069 if (!this.hidpiEmbedded_) {
2070 this.thumbnailPreloader_ = new ThumbnailLoader(
2071 this.getItem().getEntry(),
2072 ThumbnailLoader.LoaderType.CANVAS,
2074 undefined, // Media type.
2075 ThumbnailLoader.UseEmbedded.USE_EMBEDDED,
2076 // Preloaders have always higher priotity, so the preload images
2077 // are loaded as soon as possible.
2081 // Dimensions are always acquired from the metadata. For local files, it is
2082 // extracted from headers. For Drive files, it is received via the Drive API.
2083 // If the dimensions are not available, then the fallback dimensions will be
2084 // used (same as for the generic icon).
2087 if (metadata.media && metadata.media.width) {
2088 width = metadata.media.width;
2089 height = metadata.media.height;
2090 } else if (metadata.external && metadata.external.imageWidth &&
2091 metadata.external.imageHeight) {
2092 width = metadata.external.imageWidth;
2093 height = metadata.external.imageHeight;
2095 // No dimensions in metadata, then use the generic dimensions.
2096 width = Mosaic.Tile.GENERIC_ICON_SIZE;
2097 height = Mosaic.Tile.GENERIC_ICON_SIZE;
2100 if (width > height) {
2101 if (width > Mosaic.Tile.MAX_CONTENT_SIZE) {
2102 height = Math.round(height * Mosaic.Tile.MAX_CONTENT_SIZE / width);
2103 width = Mosaic.Tile.MAX_CONTENT_SIZE;
2106 if (height > Mosaic.Tile.MAX_CONTENT_SIZE) {
2107 width = Math.round(width * Mosaic.Tile.MAX_CONTENT_SIZE / height);
2108 height = Mosaic.Tile.MAX_CONTENT_SIZE;
2111 this.maxContentHeight_ = Math.max(Mosaic.Tile.MIN_CONTENT_SIZE, height);
2112 this.aspectRatio_ = width / height;
2116 * Loads an image into the tile.
2118 * The mode argument is a hint. Use low-dpi for faster response, and high-dpi
2119 * for better output, but possibly affecting performance.
2121 * If the mode is high-dpi, then a the high-dpi image is loaded, but also
2122 * low-dpi image is loaded for preloading (if available).
2123 * For the low-dpi mode, only low-dpi image is loaded. If not available, then
2124 * the high-dpi image is loaded as a fallback.
2126 * @param {!Mosaic.Tile.LoadMode} loadMode Loading mode.
2127 * @param {function(boolean)} onImageLoaded Callback when image is loaded.
2128 * The argument is true for success, false for failure.
2130 Mosaic.Tile.prototype.load = function(loadMode, onImageLoaded) {
2131 // Attaches the image to the tile and finalizes loading process for the
2132 // specified loader.
2133 var finalizeLoader = function(mode, success, loader) {
2134 if (success && this.wrapper_) {
2135 // Show the fade-in animation only when previously there was no image
2136 // attached in this tile.
2137 if (!this.imageLoaded_ && !this.imagePreloaded_)
2138 this.wrapper_.classList.add('animated');
2140 this.wrapper_.classList.remove('animated');
2142 loader.attachImage(this.wrapper_, ThumbnailLoader.FillMode.OVER_FILL);
2143 onImageLoaded(success);
2145 case Mosaic.Tile.LoadMode.LOW_DPI:
2146 this.imagePreloading_ = false;
2147 this.imagePreloaded_ = true;
2149 case Mosaic.Tile.LoadMode.HIGH_DPI:
2150 this.imageLoading_ = false;
2151 this.imageLoaded_ = true;
2156 // Always load the low-dpi image first if it is available for the fastest
2158 if (!this.imagePreloading_ && this.thumbnailPreloader_) {
2159 this.imagePreloading_ = true;
2160 this.thumbnailPreloader_.loadDetachedImage(function(success) {
2161 // Hi-dpi loaded first, ignore this call then.
2162 if (this.imageLoaded_)
2164 finalizeLoader(Mosaic.Tile.LoadMode.LOW_DPI,
2166 this.thumbnailPreloader_);
2170 // Load the high-dpi image only when it is requested, or the low-dpi is not
2172 if (!this.imageLoading_ &&
2173 (loadMode === Mosaic.Tile.LoadMode.HIGH_DPI || !this.imagePreloading_)) {
2174 this.imageLoading_ = true;
2175 this.thumbnailLoader_.loadDetachedImage(function(success) {
2176 // Cancel preloading, since the hi-dpi image is ready.
2177 if (this.thumbnailPreloader_)
2178 this.thumbnailPreloader_.cancel();
2179 finalizeLoader(Mosaic.Tile.LoadMode.HIGH_DPI,
2181 this.thumbnailLoader_);
2187 * Unloads an image from the tile.
2189 Mosaic.Tile.prototype.unload = function() {
2190 this.thumbnailLoader_.cancel();
2191 if (this.thumbnailPreloader_)
2192 this.thumbnailPreloader_.cancel();
2193 this.imagePreloaded_ = false;
2194 this.imageLoaded_ = false;
2195 this.imagePreloading_ = false;
2196 this.imageLoading_ = false;
2197 this.wrapper_.innerText = '';
2201 * Selects/unselects the tile.
2203 * @param {boolean} on True if selected.
2205 Mosaic.Tile.prototype.select = function(on) {
2207 this.setAttribute('selected', true);
2209 this.removeAttribute('selected');
2213 * Positions the tile in the mosaic.
2215 * @param {number} left Left position.
2216 * @param {number} top Top position.
2217 * @param {number} width Width.
2218 * @param {number} height Height.
2220 Mosaic.Tile.prototype.layout = function(left, top, width, height) {
2223 this.width_ = width;
2224 this.height_ = height;
2226 this.style.left = left + 'px';
2227 this.style.top = top + 'px';
2228 this.style.width = width + 'px';
2229 this.style.height = height + 'px';
2231 if (!this.wrapper_) { // First time, create DOM.
2232 this.container_.appendChild(this);
2233 var border = util.createChild(this, 'img-border');
2234 this.wrapper_ = assertInstanceof(util.createChild(border, 'img-wrapper'),
2237 if (this.hasAttribute('selected'))
2238 this.scrollIntoView(false);
2240 if (this.imageLoaded_) {
2241 this.thumbnailLoader_.attachImage(this.wrapper_,
2242 ThumbnailLoader.FillMode.OVER_FILL);
2247 * If the tile is not fully visible scroll the parent to make it fully visible.
2248 * @param {boolean=} opt_animated True, if scroll should be animated,
2251 Mosaic.Tile.prototype.scrollIntoView = function(opt_animated) {
2252 if (this.left_ === null) // Not laid out.
2256 var tileLeft = this.left_ - Mosaic.Layout.SCROLL_MARGIN;
2257 if (tileLeft < this.container_.scrollLeft) {
2258 targetPosition = tileLeft;
2260 var tileRight = this.left_ + this.width_ + Mosaic.Layout.SCROLL_MARGIN;
2261 var scrollRight = this.container_.scrollLeft + this.container_.clientWidth;
2262 if (tileRight > scrollRight)
2263 targetPosition = tileRight - this.container_.clientWidth;
2266 if (targetPosition) {
2267 if (opt_animated === false)
2268 this.container_.scrollLeft = targetPosition;
2270 this.container_.animatedScrollTo(targetPosition);
2275 * @return {ImageRect} Rectangle occupied by the tile's image,
2276 * relative to the viewport.
2278 Mosaic.Tile.prototype.getImageRect = function() {
2279 if (this.left_ === null) // Not laid out.
2282 var margin = Mosaic.Layout.SPACING / 2;
2283 return new ImageRect(this.left_ - this.container_.scrollLeft, this.top_,
2284 this.width_, this.height_).inflate(-margin, -margin);
2288 * @return {number} X coordinate of the tile center.
2290 Mosaic.Tile.prototype.getCenterX = function() {
2291 return this.left_ + Math.round(this.width_ / 2);