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