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 * The overlay displaying the image. 9 * 10 * @param {HTMLElement} container The container element. 11 * @param {Viewport} viewport The viewport. 12 * @param {MetadataCache} metadataCache The metadataCache. 13 * @constructor 14 */ 15 function ImageView(container, viewport, metadataCache) { 16 this.container_ = container; 17 this.viewport_ = viewport; 18 this.document_ = container.ownerDocument; 19 this.contentGeneration_ = 0; 20 this.displayedContentGeneration_ = 0; 21 this.displayedViewportGeneration_ = 0; 22 23 this.imageLoader_ = new ImageUtil.ImageLoader(this.document_, metadataCache); 24 // We have a separate image loader for prefetch which does not get cancelled 25 // when the selection changes. 26 this.prefetchLoader_ = new ImageUtil.ImageLoader( 27 this.document_, metadataCache); 28 29 // The content cache is used for prefetching the next image when going 30 // through the images sequentially. The real life photos can be large 31 // (18Mpix = 72Mb pixel array) so we want only the minimum amount of caching. 32 this.contentCache_ = new ImageView.Cache(2); 33 34 // We reuse previously generated screen-scale images so that going back to 35 // a recently loaded image looks instant even if the image is not in 36 // the content cache any more. Screen-scale images are small (~1Mpix) 37 // so we can afford to cache more of them. 38 this.screenCache_ = new ImageView.Cache(5); 39 this.contentCallbacks_ = []; 40 41 /** 42 * The element displaying the current content. 43 * 44 * @type {HTMLCanvasElement|HTMLVideoElement} 45 * @private 46 */ 47 this.screenImage_ = null; 48 49 this.localImageTransformFetcher_ = function(entry, callback) { 50 metadataCache.getOne(entry, 'fetchedMedia', function(fetchedMedia) { 51 callback(fetchedMedia.imageTransform); 52 }); 53 }; 54 } 55 56 /** 57 * Duration of transition between modes in ms. 58 */ 59 ImageView.MODE_TRANSITION_DURATION = 350; 60 61 /** 62 * If the user flips though images faster than this interval we do not apply 63 * the slide-in/slide-out transition. 64 */ 65 ImageView.FAST_SCROLL_INTERVAL = 300; 66 67 /** 68 * Image load type: full resolution image loaded from cache. 69 */ 70 ImageView.LOAD_TYPE_CACHED_FULL = 0; 71 72 /** 73 * Image load type: screen resolution preview loaded from cache. 74 */ 75 ImageView.LOAD_TYPE_CACHED_SCREEN = 1; 76 77 /** 78 * Image load type: image read from file. 79 */ 80 ImageView.LOAD_TYPE_IMAGE_FILE = 2; 81 82 /** 83 * Image load type: video loaded. 84 */ 85 ImageView.LOAD_TYPE_VIDEO_FILE = 3; 86 87 /** 88 * Image load type: error occurred. 89 */ 90 ImageView.LOAD_TYPE_ERROR = 4; 91 92 /** 93 * Image load type: the file contents is not available offline. 94 */ 95 ImageView.LOAD_TYPE_OFFLINE = 5; 96 97 /** 98 * The total number of load types. 99 */ 100 ImageView.LOAD_TYPE_TOTAL = 6; 101 102 ImageView.prototype = {__proto__: ImageBuffer.Overlay.prototype}; 103 104 /** 105 * Draws below overlays with the default zIndex. 106 * @return {number} Z-index. 107 */ 108 ImageView.prototype.getZIndex = function() { return -1 }; 109 110 /** 111 * Draws the image on screen. 112 */ 113 ImageView.prototype.draw = function() { 114 if (!this.contentCanvas_) // Do nothing if the image content is not set. 115 return; 116 117 var forceRepaint = false; 118 119 if (this.displayedViewportGeneration_ !== 120 this.viewport_.getCacheGeneration()) { 121 this.displayedViewportGeneration_ = this.viewport_.getCacheGeneration(); 122 123 this.setupDeviceBuffer(this.screenImage_); 124 125 forceRepaint = true; 126 } 127 128 if (forceRepaint || 129 this.displayedContentGeneration_ !== this.contentGeneration_) { 130 this.displayedContentGeneration_ = this.contentGeneration_; 131 132 ImageUtil.trace.resetTimer('paint'); 133 this.paintDeviceRect(this.viewport_.getDeviceClipped(), 134 this.contentCanvas_, this.viewport_.getImageClipped()); 135 ImageUtil.trace.reportTimer('paint'); 136 } 137 }; 138 139 /** 140 * @param {number} x X pointer position. 141 * @param {number} y Y pointer position. 142 * @param {boolean} mouseDown True if mouse is down. 143 * @return {string} CSS cursor style. 144 */ 145 ImageView.prototype.getCursorStyle = function(x, y, mouseDown) { 146 // Indicate that the image is draggable. 147 if (this.viewport_.isClipped() && 148 this.viewport_.getScreenClipped().inside(x, y)) 149 return 'move'; 150 151 return null; 152 }; 153 154 /** 155 * @param {number} x X pointer position. 156 * @param {number} y Y pointer position. 157 * @return {function} The closure to call on drag. 158 */ 159 ImageView.prototype.getDragHandler = function(x, y) { 160 var cursor = this.getCursorStyle(x, y); 161 if (cursor === 'move') { 162 // Return the handler that drags the entire image. 163 return this.viewport_.createOffsetSetter(x, y); 164 } 165 166 return null; 167 }; 168 169 /** 170 * @return {number} The cache generation. 171 */ 172 ImageView.prototype.getCacheGeneration = function() { 173 return this.contentGeneration_; 174 }; 175 176 /** 177 * Invalidates the caches to force redrawing the screen canvas. 178 */ 179 ImageView.prototype.invalidateCaches = function() { 180 this.contentGeneration_++; 181 }; 182 183 /** 184 * @return {HTMLCanvasElement} The content canvas element. 185 */ 186 ImageView.prototype.getCanvas = function() { return this.contentCanvas_ }; 187 188 /** 189 * @return {boolean} True if the a valid image is currently loaded. 190 */ 191 ImageView.prototype.hasValidImage = function() { 192 return !this.preview_ && this.contentCanvas_ && this.contentCanvas_.width; 193 }; 194 195 /** 196 * @return {HTMLVideoElement} The video element. 197 */ 198 ImageView.prototype.getVideo = function() { return this.videoElement_ }; 199 200 /** 201 * @return {HTMLCanvasElement} The cached thumbnail image. 202 */ 203 ImageView.prototype.getThumbnail = function() { return this.thumbnailCanvas_ }; 204 205 /** 206 * @return {number} The content revision number. 207 */ 208 ImageView.prototype.getContentRevision = function() { 209 return this.contentRevision_; 210 }; 211 212 /** 213 * Copies an image fragment from a full resolution canvas to a device resolution 214 * canvas. 215 * 216 * @param {Rect} deviceRect Rectangle in the device coordinates. 217 * @param {HTMLCanvasElement} canvas Full resolution canvas. 218 * @param {Rect} imageRect Rectangle in the full resolution canvas. 219 */ 220 ImageView.prototype.paintDeviceRect = function(deviceRect, canvas, imageRect) { 221 // Map screen canvas (0,0) to (deviceBounds.left, deviceBounds.top) 222 var deviceBounds = this.viewport_.getDeviceClipped(); 223 deviceRect = deviceRect.shift(-deviceBounds.left, -deviceBounds.top); 224 225 // The source canvas may have different physical size than the image size 226 // set at the viewport. Adjust imageRect accordingly. 227 var bounds = this.viewport_.getImageBounds(); 228 var scaleX = canvas.width / bounds.width; 229 var scaleY = canvas.height / bounds.height; 230 imageRect = new Rect(imageRect.left * scaleX, imageRect.top * scaleY, 231 imageRect.width * scaleX, imageRect.height * scaleY); 232 Rect.drawImage( 233 this.screenImage_.getContext('2d'), canvas, deviceRect, imageRect); 234 }; 235 236 /** 237 * Creates an overlay canvas with properties similar to the screen canvas. 238 * Useful for showing quick feedback when editing. 239 * 240 * @return {HTMLCanvasElement} Overlay canvas. 241 */ 242 ImageView.prototype.createOverlayCanvas = function() { 243 var canvas = this.document_.createElement('canvas'); 244 canvas.className = 'image'; 245 this.container_.appendChild(canvas); 246 return canvas; 247 }; 248 249 /** 250 * Sets up the canvas as a buffer in the device resolution. 251 * 252 * @param {HTMLCanvasElement} canvas The buffer canvas. 253 */ 254 ImageView.prototype.setupDeviceBuffer = function(canvas) { 255 var deviceRect = this.viewport_.getDeviceClipped(); 256 257 // Set the canvas position and size in device pixels. 258 if (canvas.width !== deviceRect.width) 259 canvas.width = deviceRect.width; 260 261 if (canvas.height !== deviceRect.height) 262 canvas.height = deviceRect.height; 263 264 canvas.style.left = deviceRect.left + 'px'; 265 canvas.style.top = deviceRect.top + 'px'; 266 267 // Scale the canvas down to screen pixels. 268 this.setTransform(canvas); 269 }; 270 271 /** 272 * @return {ImageData} A new ImageData object with a copy of the content. 273 */ 274 ImageView.prototype.copyScreenImageData = function() { 275 return this.screenImage_.getContext('2d').getImageData( 276 0, 0, this.screenImage_.width, this.screenImage_.height); 277 }; 278 279 /** 280 * @return {boolean} True if the image is currently being loaded. 281 */ 282 ImageView.prototype.isLoading = function() { 283 return this.imageLoader_.isBusy(); 284 }; 285 286 /** 287 * Cancels the current image loading operation. The callbacks will be ignored. 288 */ 289 ImageView.prototype.cancelLoad = function() { 290 this.imageLoader_.cancel(); 291 }; 292 293 /** 294 * Loads and display a new image. 295 * 296 * Loads the thumbnail first, then replaces it with the main image. 297 * Takes into account the image orientation encoded in the metadata. 298 * 299 * @param {FileEntry} entry Image entry. 300 * @param {Object} metadata Metadata. 301 * @param {Object} effect Transition effect object. 302 * @param {function(number} displayCallback Called when the image is displayed 303 * (possibly as a prevew). 304 * @param {function(number} loadCallback Called when the image is fully loaded. 305 * The parameter is the load type. 306 */ 307 ImageView.prototype.load = function(entry, metadata, effect, 308 displayCallback, loadCallback) { 309 if (effect) { 310 // Skip effects when reloading repeatedly very quickly. 311 var time = Date.now(); 312 if (this.lastLoadTime_ && 313 (time - this.lastLoadTime_) < ImageView.FAST_SCROLL_INTERVAL) { 314 effect = null; 315 } 316 this.lastLoadTime_ = time; 317 } 318 319 metadata = metadata || {}; 320 321 ImageUtil.metrics.startInterval(ImageUtil.getMetricName('DisplayTime')); 322 323 var self = this; 324 325 this.contentEntry_ = entry; 326 this.contentRevision_ = -1; 327 328 var loadingVideo = FileType.getMediaType(entry) === 'video'; 329 if (loadingVideo) { 330 var video = this.document_.createElement('video'); 331 var videoPreview = !!(metadata.thumbnail && metadata.thumbnail.url); 332 if (videoPreview) { 333 var thumbnailLoader = new ThumbnailLoader( 334 entry, 335 ThumbnailLoader.LoaderType.CANVAS, 336 metadata); 337 thumbnailLoader.loadDetachedImage(function(success) { 338 if (success) { 339 var canvas = thumbnailLoader.getImage(); 340 video.setAttribute('poster', canvas.toDataURL('image/jpeg')); 341 this.replace(video, effect); // Show the poster immediately. 342 if (displayCallback) displayCallback(); 343 } 344 }.bind(this)); 345 } 346 347 var onVideoLoad = function(error) { 348 video.removeEventListener('loadedmetadata', onVideoLoadSuccess); 349 video.removeEventListener('error', onVideoLoadError); 350 displayMainImage(ImageView.LOAD_TYPE_VIDEO_FILE, videoPreview, video, 351 error); 352 }; 353 var onVideoLoadError = onVideoLoad.bind(this, 'GALLERY_VIDEO_ERROR'); 354 var onVideoLoadSuccess = onVideoLoad.bind(this, null); 355 356 video.addEventListener('loadedmetadata', onVideoLoadSuccess); 357 video.addEventListener('error', onVideoLoadError); 358 359 video.src = entry.toURL(); 360 video.load(); 361 return; 362 } 363 364 // Cache has to be evicted in advance, so the returned cached image is not 365 // evicted later by the prefetched image. 366 this.contentCache_.evictLRU(); 367 368 var cached = this.contentCache_.getItem(this.contentEntry_); 369 if (cached) { 370 displayMainImage(ImageView.LOAD_TYPE_CACHED_FULL, 371 false /* no preview */, cached); 372 } else { 373 var cachedScreen = this.screenCache_.getItem(this.contentEntry_); 374 var imageWidth = metadata.media && metadata.media.width || 375 metadata.drive && metadata.drive.imageWidth; 376 var imageHeight = metadata.media && metadata.media.height || 377 metadata.drive && metadata.drive.imageHeight; 378 if (cachedScreen) { 379 // We have a cached screen-scale canvas, use it instead of a thumbnail. 380 displayThumbnail(ImageView.LOAD_TYPE_CACHED_SCREEN, cachedScreen); 381 // As far as the user can tell the image is loaded. We still need to load 382 // the full res image to make editing possible, but we can report now. 383 ImageUtil.metrics.recordInterval(ImageUtil.getMetricName('DisplayTime')); 384 } else if ((effect && effect.constructor.name === 'Slide') && 385 (metadata.thumbnail && metadata.thumbnail.url)) { 386 // Only show thumbnails if there is no effect or the effect is Slide. 387 // Also no thumbnail if the image is too large to be loaded. 388 var thumbnailLoader = new ThumbnailLoader( 389 entry, 390 ThumbnailLoader.LoaderType.CANVAS, 391 metadata); 392 thumbnailLoader.loadDetachedImage(function(success) { 393 displayThumbnail(ImageView.LOAD_TYPE_IMAGE_FILE, 394 success ? thumbnailLoader.getImage() : null); 395 }); 396 } else { 397 loadMainImage(ImageView.LOAD_TYPE_IMAGE_FILE, entry, 398 false /* no preview*/, 0 /* delay */); 399 } 400 } 401 402 function displayThumbnail(loadType, canvas) { 403 if (canvas) { 404 var width = null; 405 var height = null; 406 if (metadata.media) { 407 width = metadata.media.width; 408 height = metadata.media.height; 409 } 410 // If metadata.drive.present is true, the image data is loaded directly 411 // from local cache, whose size may be out of sync with the drive 412 // metadata. 413 if (metadata.drive && !metadata.drive.present) { 414 width = metadata.drive.imageWidth; 415 height = metadata.drive.imageHeight; 416 } 417 self.replace( 418 canvas, 419 effect, 420 width, 421 height, 422 true /* preview */); 423 if (displayCallback) displayCallback(); 424 } 425 loadMainImage(loadType, entry, !!canvas, 426 (effect && canvas) ? effect.getSafeInterval() : 0); 427 } 428 429 function loadMainImage(loadType, contentEntry, previewShown, delay) { 430 if (self.prefetchLoader_.isLoading(contentEntry)) { 431 // The image we need is already being prefetched. Initiating another load 432 // would be a waste. Hijack the load instead by overriding the callback. 433 self.prefetchLoader_.setCallback( 434 displayMainImage.bind(null, loadType, previewShown)); 435 436 // Swap the loaders so that the self.isLoading works correctly. 437 var temp = self.prefetchLoader_; 438 self.prefetchLoader_ = self.imageLoader_; 439 self.imageLoader_ = temp; 440 return; 441 } 442 self.prefetchLoader_.cancel(); // The prefetch was doing something useless. 443 444 self.imageLoader_.load( 445 contentEntry, 446 self.localImageTransformFetcher_, 447 displayMainImage.bind(null, loadType, previewShown), 448 delay); 449 } 450 451 function displayMainImage(loadType, previewShown, content, opt_error) { 452 if (opt_error) 453 loadType = ImageView.LOAD_TYPE_ERROR; 454 455 // If we already displayed the preview we should not replace the content if: 456 // 1. The full content failed to load. 457 // or 458 // 2. We are loading a video (because the full video is displayed in the 459 // same HTML element as the preview). 460 var animationDuration = 0; 461 if (!(previewShown && 462 (loadType === ImageView.LOAD_TYPE_ERROR || 463 loadType === ImageView.LOAD_TYPE_VIDEO_FILE))) { 464 var replaceEffect = previewShown ? null : effect; 465 animationDuration = replaceEffect ? replaceEffect.getSafeInterval() : 0; 466 self.replace(content, replaceEffect); 467 if (!previewShown && displayCallback) displayCallback(); 468 } 469 470 if (loadType !== ImageView.LOAD_TYPE_ERROR && 471 loadType !== ImageView.LOAD_TYPE_CACHED_SCREEN) { 472 ImageUtil.metrics.recordInterval(ImageUtil.getMetricName('DisplayTime')); 473 } 474 ImageUtil.metrics.recordEnum(ImageUtil.getMetricName('LoadMode'), 475 loadType, ImageView.LOAD_TYPE_TOTAL); 476 477 if (loadType === ImageView.LOAD_TYPE_ERROR && 478 !navigator.onLine && metadata.streaming) { 479 // |streaming| is set only when the file is not locally cached. 480 loadType = ImageView.LOAD_TYPE_OFFLINE; 481 } 482 if (loadCallback) loadCallback(loadType, animationDuration, opt_error); 483 } 484 }; 485 486 /** 487 * Prefetches an image. 488 * @param {FileEntry} entry The image entry. 489 * @param {number} delay Image load delay in ms. 490 */ 491 ImageView.prototype.prefetch = function(entry, delay) { 492 var self = this; 493 function prefetchDone(canvas) { 494 if (canvas.width) 495 self.contentCache_.putItem(entry, canvas); 496 } 497 498 var cached = this.contentCache_.getItem(entry); 499 if (cached) { 500 prefetchDone(cached); 501 } else if (FileType.getMediaType(entry) === 'image') { 502 // Evict the LRU item before we allocate the new canvas to avoid unneeded 503 // strain on memory. 504 this.contentCache_.evictLRU(); 505 506 this.prefetchLoader_.load( 507 entry, 508 this.localImageTransformFetcher_, 509 prefetchDone, 510 delay); 511 } 512 }; 513 514 /** 515 * Renames the current image. 516 * @param {FileEntry} newEntry The new image Entry. 517 */ 518 ImageView.prototype.changeEntry = function(newEntry) { 519 this.contentCache_.renameItem(this.contentEntry_, newEntry); 520 this.screenCache_.renameItem(this.contentEntry_, newEntry); 521 this.contentEntry_ = newEntry; 522 }; 523 524 /** 525 * Unloads content. 526 * @param {Rect} zoomToRect Target rectangle for zoom-out-effect. 527 */ 528 ImageView.prototype.unload = function(zoomToRect) { 529 if (this.unloadTimer_) { 530 clearTimeout(this.unloadTimer_); 531 this.unloadTimer_ = null; 532 } 533 if (zoomToRect && this.screenImage_) { 534 var effect = this.createZoomEffect(zoomToRect); 535 this.setTransform(this.screenImage_, effect); 536 this.screenImage_.setAttribute('fade', true); 537 this.unloadTimer_ = setTimeout(function() { 538 this.unloadTimer_ = null; 539 this.unload(null /* force unload */); 540 }.bind(this), 541 effect.getSafeInterval()); 542 return; 543 } 544 this.container_.textContent = ''; 545 this.contentCanvas_ = null; 546 this.screenImage_ = null; 547 this.videoElement_ = null; 548 }; 549 550 /** 551 * @param {HTMLCanvasElement|HTMLVideoElement} content The image element. 552 * @param {number=} opt_width Image width. 553 * @param {number=} opt_height Image height. 554 * @param {boolean=} opt_preview True if the image is a preview (not full res). 555 * @private 556 */ 557 ImageView.prototype.replaceContent_ = function( 558 content, opt_width, opt_height, opt_preview) { 559 560 if (this.contentCanvas_ && this.contentCanvas_.parentNode === this.container_) 561 this.container_.removeChild(this.contentCanvas_); 562 563 if (content.constructor.name === 'HTMLVideoElement') { 564 this.contentCanvas_ = null; 565 this.videoElement_ = content; 566 this.screenImage_ = content; 567 this.screenImage_.className = 'image'; 568 this.container_.appendChild(this.screenImage_); 569 this.videoElement_.play(); 570 return; 571 } 572 573 this.screenImage_ = this.document_.createElement('canvas'); 574 this.screenImage_.className = 'image'; 575 576 this.videoElement_ = null; 577 this.contentCanvas_ = content; 578 this.invalidateCaches(); 579 this.viewport_.setImageSize( 580 opt_width || this.contentCanvas_.width, 581 opt_height || this.contentCanvas_.height); 582 this.viewport_.fitImage(); 583 this.viewport_.update(); 584 this.draw(); 585 586 this.container_.appendChild(this.screenImage_); 587 588 this.preview_ = opt_preview; 589 // If this is not a thumbnail, cache the content and the screen-scale image. 590 if (this.hasValidImage()) { 591 // Insert the full resolution canvas into DOM so that it can be printed. 592 this.container_.appendChild(this.contentCanvas_); 593 this.contentCanvas_.classList.add('fullres'); 594 595 this.contentCache_.putItem(this.contentEntry_, this.contentCanvas_, true); 596 this.screenCache_.putItem(this.contentEntry_, this.screenImage_); 597 598 // TODO(kaznacheev): It is better to pass screenImage_ as it is usually 599 // much smaller than contentCanvas_ and still contains the entire image. 600 // Once we implement zoom/pan we should pass contentCanvas_ instead. 601 this.updateThumbnail_(this.screenImage_); 602 603 this.contentRevision_++; 604 for (var i = 0; i !== this.contentCallbacks_.length; i++) { 605 try { 606 this.contentCallbacks_[i](); 607 } catch (e) { 608 console.error(e); 609 } 610 } 611 } 612 }; 613 614 /** 615 * Adds a listener for content changes. 616 * @param {function} callback Callback. 617 */ 618 ImageView.prototype.addContentCallback = function(callback) { 619 this.contentCallbacks_.push(callback); 620 }; 621 622 /** 623 * Updates the cached thumbnail image. 624 * 625 * @param {HTMLCanvasElement} canvas The source canvas. 626 * @private 627 */ 628 ImageView.prototype.updateThumbnail_ = function(canvas) { 629 ImageUtil.trace.resetTimer('thumb'); 630 var pixelCount = 10000; 631 var downScale = 632 Math.max(1, Math.sqrt(canvas.width * canvas.height / pixelCount)); 633 634 this.thumbnailCanvas_ = canvas.ownerDocument.createElement('canvas'); 635 this.thumbnailCanvas_.width = Math.round(canvas.width / downScale); 636 this.thumbnailCanvas_.height = Math.round(canvas.height / downScale); 637 Rect.drawImage(this.thumbnailCanvas_.getContext('2d'), canvas); 638 ImageUtil.trace.reportTimer('thumb'); 639 }; 640 641 /** 642 * Replaces the displayed image, possibly with slide-in animation. 643 * 644 * @param {HTMLCanvasElement|HTMLVideoElement} content The image element. 645 * @param {Object=} opt_effect Transition effect object. 646 * @param {number=} opt_width Image width. 647 * @param {number=} opt_height Image height. 648 * @param {boolean=} opt_preview True if the image is a preview (not full res). 649 */ 650 ImageView.prototype.replace = function( 651 content, opt_effect, opt_width, opt_height, opt_preview) { 652 var oldScreenImage = this.screenImage_; 653 654 this.replaceContent_(content, opt_width, opt_height, opt_preview); 655 if (!opt_effect) { 656 if (oldScreenImage) 657 oldScreenImage.parentNode.removeChild(oldScreenImage); 658 return; 659 } 660 661 var newScreenImage = this.screenImage_; 662 663 if (oldScreenImage) 664 ImageUtil.setAttribute(newScreenImage, 'fade', true); 665 this.setTransform(newScreenImage, opt_effect, 0 /* instant */); 666 667 setTimeout(function() { 668 this.setTransform(newScreenImage, null, 669 opt_effect && opt_effect.getDuration()); 670 if (oldScreenImage) { 671 ImageUtil.setAttribute(newScreenImage, 'fade', false); 672 ImageUtil.setAttribute(oldScreenImage, 'fade', true); 673 console.assert(opt_effect.getReverse, 'Cannot revert an effect.'); 674 var reverse = opt_effect.getReverse(); 675 this.setTransform(oldScreenImage, reverse); 676 setTimeout(function() { 677 if (oldScreenImage.parentNode) 678 oldScreenImage.parentNode.removeChild(oldScreenImage); 679 }, reverse.getSafeInterval()); 680 } 681 }.bind(this), 0); 682 }; 683 684 /** 685 * @param {HTMLCanvasElement|HTMLVideoElement} element The element to transform. 686 * @param {ImageView.Effect=} opt_effect The effect to apply. 687 * @param {number=} opt_duration Transition duration. 688 */ 689 ImageView.prototype.setTransform = function(element, opt_effect, opt_duration) { 690 if (!opt_effect) 691 opt_effect = new ImageView.Effect.None(); 692 if (typeof opt_duration !== 'number') 693 opt_duration = opt_effect.getDuration(); 694 element.style.webkitTransitionDuration = opt_duration + 'ms'; 695 element.style.webkitTransitionTimingFunction = opt_effect.getTiming(); 696 element.style.webkitTransform = opt_effect.transform(element, this.viewport_); 697 }; 698 699 /** 700 * @param {Rect} screenRect Target rectangle in screen coordinates. 701 * @return {ImageView.Effect.Zoom} Zoom effect object. 702 */ 703 ImageView.prototype.createZoomEffect = function(screenRect) { 704 return new ImageView.Effect.Zoom( 705 this.viewport_.screenToDeviceRect(screenRect), 706 null /* use viewport */, 707 ImageView.MODE_TRANSITION_DURATION); 708 }; 709 710 /** 711 * Visualizes crop or rotate operation. Hide the old image instantly, animate 712 * the new image to visualize the operation. 713 * 714 * @param {HTMLCanvasElement} canvas New content canvas. 715 * @param {Rect} imageCropRect The crop rectangle in image coordinates. 716 * Null for rotation operations. 717 * @param {number} rotate90 Rotation angle in 90 degree increments. 718 * @return {number} Animation duration. 719 */ 720 ImageView.prototype.replaceAndAnimate = function( 721 canvas, imageCropRect, rotate90) { 722 var oldScale = this.viewport_.getScale(); 723 var deviceCropRect = imageCropRect && this.viewport_.screenToDeviceRect( 724 this.viewport_.imageToScreenRect(imageCropRect)); 725 726 var oldScreenImage = this.screenImage_; 727 this.replaceContent_(canvas); 728 var newScreenImage = this.screenImage_; 729 730 // Display the new canvas, initially transformed. 731 var deviceFullRect = this.viewport_.getDeviceClipped(); 732 733 var effect = rotate90 ? 734 new ImageView.Effect.Rotate( 735 oldScale / this.viewport_.getScale(), -rotate90) : 736 new ImageView.Effect.Zoom(deviceCropRect, deviceFullRect); 737 738 this.setTransform(newScreenImage, effect, 0 /* instant */); 739 740 oldScreenImage.parentNode.appendChild(newScreenImage); 741 oldScreenImage.parentNode.removeChild(oldScreenImage); 742 743 // Let the layout fire, then animate back to non-transformed state. 744 setTimeout( 745 this.setTransform.bind( 746 this, newScreenImage, null, effect.getDuration()), 747 0); 748 749 return effect.getSafeInterval(); 750 }; 751 752 /** 753 * Visualizes "undo crop". Shrink the current image to the given crop rectangle 754 * while fading in the new image. 755 * 756 * @param {HTMLCanvasElement} canvas New content canvas. 757 * @param {Rect} imageCropRect The crop rectangle in image coordinates. 758 * @return {number} Animation duration. 759 */ 760 ImageView.prototype.animateAndReplace = function(canvas, imageCropRect) { 761 var deviceFullRect = this.viewport_.getDeviceClipped(); 762 var oldScale = this.viewport_.getScale(); 763 764 var oldScreenImage = this.screenImage_; 765 this.replaceContent_(canvas); 766 var newScreenImage = this.screenImage_; 767 768 var deviceCropRect = this.viewport_.screenToDeviceRect( 769 this.viewport_.imageToScreenRect(imageCropRect)); 770 771 var setFade = ImageUtil.setAttribute.bind(null, newScreenImage, 'fade'); 772 setFade(true); 773 oldScreenImage.parentNode.insertBefore(newScreenImage, oldScreenImage); 774 775 var effect = new ImageView.Effect.Zoom(deviceCropRect, deviceFullRect); 776 // Animate to the transformed state. 777 this.setTransform(oldScreenImage, effect); 778 779 setTimeout(setFade.bind(null, false), 0); 780 781 setTimeout(function() { 782 if (oldScreenImage.parentNode) 783 oldScreenImage.parentNode.removeChild(oldScreenImage); 784 }, effect.getSafeInterval()); 785 786 return effect.getSafeInterval(); 787 }; 788 789 790 /** 791 * Generic cache with a limited capacity and LRU eviction. 792 * @param {number} capacity Maximum number of cached item. 793 * @constructor 794 */ 795 ImageView.Cache = function(capacity) { 796 this.capacity_ = capacity; 797 this.map_ = {}; 798 this.order_ = []; 799 }; 800 801 /** 802 * Fetches the item from the cache. 803 * @param {FileEntry} entry The entry. 804 * @return {Object} The cached item. 805 */ 806 ImageView.Cache.prototype.getItem = function(entry) { 807 return this.map_[entry.toURL()]; 808 }; 809 810 /** 811 * Puts the item into the cache. 812 * 813 * @param {FileEntry} entry The entry. 814 * @param {Object} item The item object. 815 * @param {boolean=} opt_keepLRU True if the LRU order should not be modified. 816 */ 817 ImageView.Cache.prototype.putItem = function(entry, item, opt_keepLRU) { 818 var pos = this.order_.indexOf(entry.toURL()); 819 820 if ((pos >= 0) !== (entry.toURL() in this.map_)) 821 throw new Error('Inconsistent cache state'); 822 823 if (entry.toURL() in this.map_) { 824 if (!opt_keepLRU) { 825 // Move to the end (most recently used). 826 this.order_.splice(pos, 1); 827 this.order_.push(entry.toURL()); 828 } 829 } else { 830 this.evictLRU(); 831 this.order_.push(entry.toURL()); 832 } 833 834 if ((pos >= 0) && (item !== this.map_[entry.toURL()])) 835 this.deleteItem_(this.map_[entry.toURL()]); 836 this.map_[entry.toURL()] = item; 837 838 if (this.order_.length > this.capacity_) 839 throw new Error('Exceeded cache capacity'); 840 }; 841 842 /** 843 * Evicts the least recently used items. 844 */ 845 ImageView.Cache.prototype.evictLRU = function() { 846 if (this.order_.length === this.capacity_) { 847 var url = this.order_.shift(); 848 this.deleteItem_(this.map_[url]); 849 delete this.map_[url]; 850 } 851 }; 852 853 /** 854 * Changes the Entry. 855 * @param {FileEntry} oldEntry The old Entry. 856 * @param {FileEntry} newEntry The new Entry. 857 */ 858 ImageView.Cache.prototype.renameItem = function(oldEntry, newEntry) { 859 if (util.isSameEntry(oldEntry, newEntry)) 860 return; // No need to rename. 861 862 var pos = this.order_.indexOf(oldEntry.toURL()); 863 if (pos < 0) 864 return; // Not cached. 865 866 this.order_[pos] = newEntry.toURL(); 867 this.map_[newEntry.toURL()] = this.map_[oldEntry.toURL()]; 868 delete this.map_[oldEntry.toURL()]; 869 }; 870 871 /** 872 * Disposes an object. 873 * 874 * @param {Object} item The item object. 875 * @private 876 */ 877 ImageView.Cache.prototype.deleteItem_ = function(item) { 878 // Trick to reduce memory usage without waiting for gc. 879 if (item instanceof HTMLCanvasElement) { 880 // If the canvas is being used somewhere else (eg. displayed on the screen), 881 // it will be cleared. 882 item.width = 0; 883 item.height = 0; 884 } 885 }; 886 887 /* Transition effects */ 888 889 /** 890 * Base class for effects. 891 * 892 * @param {number} duration Duration in ms. 893 * @param {string=} opt_timing CSS transition timing function name. 894 * @constructor 895 */ 896 ImageView.Effect = function(duration, opt_timing) { 897 this.duration_ = duration; 898 this.timing_ = opt_timing || 'linear'; 899 }; 900 901 /** 902 * 903 */ 904 ImageView.Effect.DEFAULT_DURATION = 180; 905 906 /** 907 * 908 */ 909 ImageView.Effect.MARGIN = 100; 910 911 /** 912 * @return {number} Effect duration in ms. 913 */ 914 ImageView.Effect.prototype.getDuration = function() { return this.duration_ }; 915 916 /** 917 * @return {number} Delay in ms since the beginning of the animation after which 918 * it is safe to perform CPU-heavy operations without disrupting the animation. 919 */ 920 ImageView.Effect.prototype.getSafeInterval = function() { 921 return this.getDuration() + ImageView.Effect.MARGIN; 922 }; 923 924 /** 925 * @return {string} CSS transition timing function name. 926 */ 927 ImageView.Effect.prototype.getTiming = function() { return this.timing_ }; 928 929 /** 930 * @param {HTMLCanvasElement|HTMLVideoElement} element Element. 931 * @return {number} Preferred pixel ration to use with this element. 932 * @private 933 */ 934 ImageView.Effect.getPixelRatio_ = function(element) { 935 if (element.constructor.name === 'HTMLCanvasElement') 936 return Viewport.getDevicePixelRatio(); 937 else 938 return 1; 939 }; 940 941 /** 942 * Default effect. It is not a no-op as it needs to adjust a canvas scale 943 * for devicePixelRatio. 944 * 945 * @constructor 946 */ 947 ImageView.Effect.None = function() { 948 ImageView.Effect.call(this, 0); 949 }; 950 951 /** 952 * Inherits from ImageView.Effect. 953 */ 954 ImageView.Effect.None.prototype = { __proto__: ImageView.Effect.prototype }; 955 956 /** 957 * @param {HTMLCanvasElement|HTMLVideoElement} element Element. 958 * @return {string} Transform string. 959 */ 960 ImageView.Effect.None.prototype.transform = function(element) { 961 var ratio = ImageView.Effect.getPixelRatio_(element); 962 return 'scale(' + (1 / ratio) + ')'; 963 }; 964 965 /** 966 * Slide effect. 967 * 968 * @param {number} direction -1 for left, 1 for right. 969 * @param {boolean=} opt_slow True if slow (as in slideshow). 970 * @constructor 971 */ 972 ImageView.Effect.Slide = function Slide(direction, opt_slow) { 973 ImageView.Effect.call(this, 974 opt_slow ? 800 : ImageView.Effect.DEFAULT_DURATION, 'ease-in-out'); 975 this.direction_ = direction; 976 this.slow_ = opt_slow; 977 this.shift_ = opt_slow ? 100 : 40; 978 if (this.direction_ < 0) this.shift_ = -this.shift_; 979 }; 980 981 /** 982 * Inherits from ImageView.Effect. 983 */ 984 ImageView.Effect.Slide.prototype = { __proto__: ImageView.Effect.prototype }; 985 986 /** 987 * @return {ImageView.Effect.Slide} Reverse Slide effect. 988 */ 989 ImageView.Effect.Slide.prototype.getReverse = function() { 990 return new ImageView.Effect.Slide(-this.direction_, this.slow_); 991 }; 992 993 /** 994 * @param {HTMLCanvasElement|HTMLVideoElement} element Element. 995 * @return {string} Transform string. 996 */ 997 ImageView.Effect.Slide.prototype.transform = function(element) { 998 var ratio = ImageView.Effect.getPixelRatio_(element); 999 return 'scale(' + (1 / ratio) + ') translate(' + this.shift_ + 'px, 0px)'; 1000 }; 1001 1002 /** 1003 * Zoom effect. 1004 * 1005 * Animates the original rectangle to the target rectangle. Both parameters 1006 * should be given in device coordinates (accounting for devicePixelRatio). 1007 * 1008 * @param {Rect} deviceTargetRect Target rectangle. 1009 * @param {Rect=} opt_deviceOriginalRect Original rectangle. If omitted, 1010 * the full viewport will be used at the time of |transform| call. 1011 * @param {number=} opt_duration Duration in ms. 1012 * @constructor 1013 */ 1014 ImageView.Effect.Zoom = function( 1015 deviceTargetRect, opt_deviceOriginalRect, opt_duration) { 1016 ImageView.Effect.call(this, 1017 opt_duration || ImageView.Effect.DEFAULT_DURATION); 1018 this.target_ = deviceTargetRect; 1019 this.original_ = opt_deviceOriginalRect; 1020 }; 1021 1022 /** 1023 * Inherits from ImageView.Effect. 1024 */ 1025 ImageView.Effect.Zoom.prototype = { __proto__: ImageView.Effect.prototype }; 1026 1027 /** 1028 * @param {HTMLCanvasElement|HTMLVideoElement} element Element. 1029 * @param {Viewport} viewport Viewport. 1030 * @return {string} Transform string. 1031 */ 1032 ImageView.Effect.Zoom.prototype.transform = function(element, viewport) { 1033 if (!this.original_) 1034 this.original_ = viewport.getDeviceClipped(); 1035 1036 var ratio = ImageView.Effect.getPixelRatio_(element); 1037 1038 var dx = (this.target_.left + this.target_.width / 2) - 1039 (this.original_.left + this.original_.width / 2); 1040 var dy = (this.target_.top + this.target_.height / 2) - 1041 (this.original_.top + this.original_.height / 2); 1042 1043 var scaleX = this.target_.width / this.original_.width; 1044 var scaleY = this.target_.height / this.original_.height; 1045 1046 return 'translate(' + (dx / ratio) + 'px,' + (dy / ratio) + 'px) ' + 1047 'scaleX(' + (scaleX / ratio) + ') scaleY(' + (scaleY / ratio) + ')'; 1048 }; 1049 1050 /** 1051 * Rotate effect. 1052 * 1053 * @param {number} scale Scale. 1054 * @param {number} rotate90 Rotation in 90 degrees increments. 1055 * @constructor 1056 */ 1057 ImageView.Effect.Rotate = function(scale, rotate90) { 1058 ImageView.Effect.call(this, ImageView.Effect.DEFAULT_DURATION); 1059 this.scale_ = scale; 1060 this.rotate90_ = rotate90; 1061 }; 1062 1063 /** 1064 * Inherits from ImageView.Effect. 1065 */ 1066 ImageView.Effect.Rotate.prototype = { __proto__: ImageView.Effect.prototype }; 1067 1068 /** 1069 * @param {HTMLCanvasElement|HTMLVideoElement} element Element. 1070 * @return {string} Transform string. 1071 */ 1072 ImageView.Effect.Rotate.prototype.transform = function(element) { 1073 var ratio = ImageView.Effect.getPixelRatio_(element); 1074 return 'rotate(' + (this.rotate90_ * 90) + 'deg) ' + 1075 'scale(' + (this.scale_ / ratio) + ')'; 1076 }; 1077