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