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