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 * 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(url, callback) { 50 metadataCache.get(url, '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: screeb 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 * Draw below overlays with the default zIndex. 106 * @return {number} Z-index. 107 */ 108 ImageView.prototype.getZIndex = function() { return -1 }; 109 110 /** 111 * Draw 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 * Invalidate 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 * Copy 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 * Create 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 * Cancel the current image loading operation. The callbacks will be ignored. 288 */ 289 ImageView.prototype.cancelLoad = function() { 290 this.imageLoader_.cancel(); 291 }; 292 293 /** 294 * Load 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 {string} url Image url. 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(url, 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.contentID_ = url; 326 this.contentRevision_ = -1; 327 328 var loadingVideo = FileType.getMediaType(url) == 'video'; 329 if (loadingVideo) { 330 var video = this.document_.createElement('video'); 331 var videoPreview = !!(metadata.thumbnail && metadata.thumbnail.url); 332 if (videoPreview) { 333 video.setAttribute('poster', metadata.thumbnail.url); 334 this.replace(video, effect); // Show the poster immediately. 335 if (displayCallback) displayCallback(); 336 } 337 338 var onVideoLoad = function(error) { 339 video.removeEventListener('loadedmetadata', onVideoLoadSuccess); 340 video.removeEventListener('error', onVideoLoadError); 341 displayMainImage(ImageView.LOAD_TYPE_VIDEO_FILE, videoPreview, video, 342 error); 343 }; 344 var onVideoLoadError = onVideoLoad.bind(this, 'VIDEO_ERROR'); 345 var onVideoLoadSuccess = onVideoLoad.bind(this, null); 346 347 video.addEventListener('loadedmetadata', onVideoLoadSuccess); 348 video.addEventListener('error', onVideoLoadError); 349 350 video.src = url; 351 video.load(); 352 return; 353 } 354 355 // Cache has to be evicted in advance, so the returned cached image is not 356 // evicted later by the prefetched image. 357 this.contentCache_.evictLRU(); 358 359 var cached = this.contentCache_.getItem(this.contentID_); 360 if (cached) { 361 displayMainImage(ImageView.LOAD_TYPE_CACHED_FULL, 362 false /* no preview */, cached); 363 } else { 364 var cachedScreen = this.screenCache_.getItem(this.contentID_); 365 if (cachedScreen) { 366 // We have a cached screen-scale canvas, use it instead of a thumbnail. 367 displayThumbnail(ImageView.LOAD_TYPE_CACHED_SCREEN, cachedScreen); 368 // As far as the user can tell the image is loaded. We still need to load 369 // the full res image to make editing possible, but we can report now. 370 ImageUtil.metrics.recordInterval(ImageUtil.getMetricName('DisplayTime')); 371 } else if ((!effect || (effect.constructor.name == 'Slide')) && 372 metadata.thumbnail && metadata.thumbnail.url && 373 !(metadata.media && ImageUtil.ImageLoader.isTooLarge(metadata.media))) { 374 // Only show thumbnails if there is no effect or the effect is Slide. 375 // Also no thumbnail if the image is too large to be loaded. 376 this.imageLoader_.load( 377 metadata.thumbnail.url, 378 function(url, callback) { callback(metadata.thumbnail.transform); }, 379 displayThumbnail.bind(null, ImageView.LOAD_TYPE_IMAGE_FILE)); 380 } else { 381 loadMainImage(ImageView.LOAD_TYPE_IMAGE_FILE, url, 382 false /* no preview*/, 0 /* delay */); 383 } 384 } 385 386 function displayThumbnail(loadType, canvas) { 387 var previewAvailable = !!canvas.width; 388 if (previewAvailable) { 389 // The thumbnail may have different aspect ratio than the main image. 390 // Force the main image proportions to avoid flicker. 391 if (!metadata.media.width) { 392 // We do not know the main image size, but chances are that it is large 393 // enough. Show the thumbnail at the maximum possible scale. 394 var bounds = self.viewport_.getScreenBounds(); 395 var scale = Math.min(bounds.width / canvas.width, 396 bounds.height / canvas.height); 397 self.replace(canvas, effect, 398 canvas.width * scale, canvas.height * scale, true /* preview */); 399 } else { 400 self.replace(canvas, effect, 401 metadata.media.width, metadata.media.height, true /* preview */); 402 } 403 if (displayCallback) displayCallback(); 404 } 405 loadMainImage(loadType, url, previewAvailable, 406 (effect && previewAvailable) ? effect.getSafeInterval() : 0); 407 } 408 409 function loadMainImage(loadType, contentURL, previewShown, delay) { 410 if (self.prefetchLoader_.isLoading(contentURL)) { 411 // The image we need is already being prefetched. Initiating another load 412 // would be a waste. Hijack the load instead by overriding the callback. 413 self.prefetchLoader_.setCallback( 414 displayMainImage.bind(null, loadType, previewShown)); 415 416 // Swap the loaders so that the self.isLoading works correctly. 417 var temp = self.prefetchLoader_; 418 self.prefetchLoader_ = self.imageLoader_; 419 self.imageLoader_ = temp; 420 return; 421 } 422 self.prefetchLoader_.cancel(); // The prefetch was doing something useless. 423 424 self.imageLoader_.load( 425 contentURL, 426 self.localImageTransformFetcher_, 427 displayMainImage.bind(null, loadType, previewShown), 428 delay); 429 } 430 431 function displayMainImage(loadType, previewShown, content, opt_error) { 432 if (opt_error) 433 loadType = ImageView.LOAD_TYPE_ERROR; 434 435 // If we already displayed the preview we should not replace the content if: 436 // 1. The full content failed to load. 437 // or 438 // 2. We are loading a video (because the full video is displayed in the 439 // same HTML element as the preview). 440 var animationDuration = 0; 441 if (!(previewShown && 442 (loadType == ImageView.LOAD_TYPE_ERROR || 443 loadType == ImageView.LOAD_TYPE_VIDEO_FILE))) { 444 var replaceEffect = previewShown ? null : effect; 445 animationDuration = replaceEffect ? replaceEffect.getSafeInterval() : 0; 446 self.replace(content, replaceEffect); 447 if (!previewShown && displayCallback) displayCallback(); 448 } 449 450 if (loadType != ImageView.LOAD_TYPE_ERROR && 451 loadType != ImageView.LOAD_TYPE_CACHED_SCREEN) { 452 ImageUtil.metrics.recordInterval(ImageUtil.getMetricName('DisplayTime')); 453 } 454 ImageUtil.metrics.recordEnum(ImageUtil.getMetricName('LoadMode'), 455 loadType, ImageView.LOAD_TYPE_TOTAL); 456 457 if (loadType == ImageView.LOAD_TYPE_ERROR && 458 !navigator.onLine && metadata.streaming) { 459 // |streaming| is set only when the file is not locally cached. 460 loadType = ImageView.LOAD_TYPE_OFFLINE; 461 } 462 if (loadCallback) loadCallback(loadType, animationDuration, opt_error); 463 } 464 }; 465 466 /** 467 * Prefetch an image. 468 * 469 * @param {string} url The image url. 470 * @param {number} delay Image load delay in ms. 471 */ 472 ImageView.prototype.prefetch = function(url, delay) { 473 var self = this; 474 function prefetchDone(canvas) { 475 if (canvas.width) 476 self.contentCache_.putItem(url, canvas); 477 } 478 479 var cached = this.contentCache_.getItem(url); 480 if (cached) { 481 prefetchDone(cached); 482 } else if (FileType.getMediaType(url) == 'image') { 483 // Evict the LRU item before we allocate the new canvas to avoid unneeded 484 // strain on memory. 485 this.contentCache_.evictLRU(); 486 487 this.prefetchLoader_.load( 488 url, 489 this.localImageTransformFetcher_, 490 prefetchDone, 491 delay); 492 } 493 }; 494 495 /** 496 * Rename the current image. 497 * 498 * @param {string} newUrl The new image url. 499 */ 500 ImageView.prototype.changeUrl = function(newUrl) { 501 this.contentCache_.renameItem(this.contentID_, newUrl); 502 this.screenCache_.renameItem(this.contentID_, newUrl); 503 this.contentID_ = newUrl; 504 }; 505 506 /** 507 * Unload content. 508 * 509 * @param {Rect} zoomToRect Target rectangle for zoom-out-effect. 510 */ 511 ImageView.prototype.unload = function(zoomToRect) { 512 if (this.unloadTimer_) { 513 clearTimeout(this.unloadTimer_); 514 this.unloadTimer_ = null; 515 } 516 if (zoomToRect && this.screenImage_) { 517 var effect = this.createZoomEffect(zoomToRect); 518 this.setTransform(this.screenImage_, effect); 519 this.screenImage_.setAttribute('fade', true); 520 this.unloadTimer_ = setTimeout(function() { 521 this.unloadTimer_ = null; 522 this.unload(null /* force unload */); 523 }.bind(this), 524 effect.getSafeInterval()); 525 return; 526 } 527 this.container_.textContent = ''; 528 this.contentCanvas_ = null; 529 this.screenImage_ = null; 530 this.videoElement_ = null; 531 }; 532 533 /** 534 * 535 * @param {HTMLCanvasElement|HTMLVideoElement} content The image element. 536 * @param {number=} opt_width Image width. 537 * @param {number=} opt_height Image height. 538 * @param {boolean=} opt_preview True if the image is a preview (not full res). 539 * @private 540 */ 541 ImageView.prototype.replaceContent_ = function( 542 content, opt_width, opt_height, opt_preview) { 543 544 if (this.contentCanvas_ && this.contentCanvas_.parentNode == this.container_) 545 this.container_.removeChild(this.contentCanvas_); 546 547 if (content.constructor.name == 'HTMLVideoElement') { 548 this.contentCanvas_ = null; 549 this.videoElement_ = content; 550 this.screenImage_ = content; 551 this.screenImage_.className = 'image'; 552 this.container_.appendChild(this.screenImage_); 553 this.videoElement_.play(); 554 return; 555 } 556 557 this.screenImage_ = this.document_.createElement('canvas'); 558 this.screenImage_.className = 'image'; 559 560 this.videoElement_ = null; 561 this.contentCanvas_ = content; 562 this.invalidateCaches(); 563 this.viewport_.setImageSize( 564 opt_width || this.contentCanvas_.width, 565 opt_height || this.contentCanvas_.height); 566 this.viewport_.fitImage(); 567 this.viewport_.update(); 568 this.draw(); 569 570 this.container_.appendChild(this.screenImage_); 571 572 this.preview_ = opt_preview; 573 // If this is not a thumbnail, cache the content and the screen-scale image. 574 if (this.hasValidImage()) { 575 // Insert the full resolution canvas into DOM so that it can be printed. 576 this.container_.appendChild(this.contentCanvas_); 577 this.contentCanvas_.classList.add('fullres'); 578 579 this.contentCache_.putItem(this.contentID_, this.contentCanvas_, true); 580 this.screenCache_.putItem(this.contentID_, this.screenImage_); 581 582 // TODO(kaznacheev): It is better to pass screenImage_ as it is usually 583 // much smaller than contentCanvas_ and still contains the entire image. 584 // Once we implement zoom/pan we should pass contentCanvas_ instead. 585 this.updateThumbnail_(this.screenImage_); 586 587 this.contentRevision_++; 588 for (var i = 0; i != this.contentCallbacks_.length; i++) { 589 try { 590 this.contentCallbacks_[i](); 591 } catch (e) { 592 console.error(e); 593 } 594 } 595 } 596 }; 597 598 /** 599 * Add a listener for content changes. 600 * @param {function} callback Callback. 601 */ 602 ImageView.prototype.addContentCallback = function(callback) { 603 this.contentCallbacks_.push(callback); 604 }; 605 606 /** 607 * Update the cached thumbnail image. 608 * 609 * @param {HTMLCanvasElement} canvas The source canvas. 610 * @private 611 */ 612 ImageView.prototype.updateThumbnail_ = function(canvas) { 613 ImageUtil.trace.resetTimer('thumb'); 614 var pixelCount = 10000; 615 var downScale = 616 Math.max(1, Math.sqrt(canvas.width * canvas.height / pixelCount)); 617 618 this.thumbnailCanvas_ = canvas.ownerDocument.createElement('canvas'); 619 this.thumbnailCanvas_.width = Math.round(canvas.width / downScale); 620 this.thumbnailCanvas_.height = Math.round(canvas.height / downScale); 621 Rect.drawImage(this.thumbnailCanvas_.getContext('2d'), canvas); 622 ImageUtil.trace.reportTimer('thumb'); 623 }; 624 625 /** 626 * Replace the displayed image, possibly with slide-in animation. 627 * 628 * @param {HTMLCanvasElement|HTMLVideoElement} content The image element. 629 * @param {Object=} opt_effect Transition effect object. 630 * @param {number=} opt_width Image width. 631 * @param {number=} opt_height Image height. 632 * @param {boolean=} opt_preview True if the image is a preview (not full res). 633 */ 634 ImageView.prototype.replace = function( 635 content, opt_effect, opt_width, opt_height, opt_preview) { 636 var oldScreenImage = this.screenImage_; 637 638 this.replaceContent_(content, opt_width, opt_height, opt_preview); 639 if (!opt_effect) { 640 if (oldScreenImage) 641 oldScreenImage.parentNode.removeChild(oldScreenImage); 642 return; 643 } 644 645 var newScreenImage = this.screenImage_; 646 647 if (oldScreenImage) 648 ImageUtil.setAttribute(newScreenImage, 'fade', true); 649 this.setTransform(newScreenImage, opt_effect, 0 /* instant */); 650 651 setTimeout(function() { 652 this.setTransform(newScreenImage, null, 653 opt_effect && opt_effect.getDuration()); 654 if (oldScreenImage) { 655 ImageUtil.setAttribute(newScreenImage, 'fade', false); 656 ImageUtil.setAttribute(oldScreenImage, 'fade', true); 657 console.assert(opt_effect.getReverse, 'Cannot revert an effect.'); 658 var reverse = opt_effect.getReverse(); 659 this.setTransform(oldScreenImage, reverse); 660 setTimeout(function() { 661 if (oldScreenImage.parentNode) 662 oldScreenImage.parentNode.removeChild(oldScreenImage); 663 }, reverse.getSafeInterval()); 664 } 665 }.bind(this), 0); 666 }; 667 668 /** 669 * @param {HTMLCanvasElement|HTMLVideoElement} element The element to transform. 670 * @param {ImageView.Effect=} opt_effect The effect to apply. 671 * @param {number=} opt_duration Transition duration. 672 */ 673 ImageView.prototype.setTransform = function(element, opt_effect, opt_duration) { 674 if (!opt_effect) 675 opt_effect = new ImageView.Effect.None(); 676 if (typeof opt_duration != 'number') 677 opt_duration = opt_effect.getDuration(); 678 element.style.webkitTransitionDuration = opt_duration + 'ms'; 679 element.style.webkitTransitionTimingFunction = opt_effect.getTiming(); 680 element.style.webkitTransform = opt_effect.transform(element, this.viewport_); 681 }; 682 683 /** 684 * @param {Rect} screenRect Target rectangle in screen coordinates. 685 * @return {ImageView.Effect.Zoom} Zoom effect object. 686 */ 687 ImageView.prototype.createZoomEffect = function(screenRect) { 688 return new ImageView.Effect.Zoom( 689 this.viewport_.screenToDeviceRect(screenRect), 690 null /* use viewport */, 691 ImageView.MODE_TRANSITION_DURATION); 692 }; 693 694 /** 695 * Visualize crop or rotate operation. Hide the old image instantly, animate 696 * the new image to visualize the operation. 697 * 698 * @param {HTMLCanvasElement} canvas New content canvas. 699 * @param {Rect} imageCropRect The crop rectangle in image coordinates. 700 * Null for rotation operations. 701 * @param {number} rotate90 Rotation angle in 90 degree increments. 702 * @return {number} Animation duration. 703 */ 704 ImageView.prototype.replaceAndAnimate = function( 705 canvas, imageCropRect, rotate90) { 706 var oldScale = this.viewport_.getScale(); 707 var deviceCropRect = imageCropRect && this.viewport_.screenToDeviceRect( 708 this.viewport_.imageToScreenRect(imageCropRect)); 709 710 var oldScreenImage = this.screenImage_; 711 this.replaceContent_(canvas); 712 var newScreenImage = this.screenImage_; 713 714 // Display the new canvas, initially transformed. 715 var deviceFullRect = this.viewport_.getDeviceClipped(); 716 717 var effect = rotate90 ? 718 new ImageView.Effect.Rotate( 719 oldScale / this.viewport_.getScale(), -rotate90) : 720 new ImageView.Effect.Zoom(deviceCropRect, deviceFullRect); 721 722 this.setTransform(newScreenImage, effect, 0 /* instant */); 723 724 oldScreenImage.parentNode.appendChild(newScreenImage); 725 oldScreenImage.parentNode.removeChild(oldScreenImage); 726 727 // Let the layout fire, then animate back to non-transformed state. 728 setTimeout( 729 this.setTransform.bind( 730 this, newScreenImage, null, effect.getDuration()), 731 0); 732 733 return effect.getSafeInterval(); 734 }; 735 736 /** 737 * Visualize "undo crop". Shrink the current image to the given crop rectangle 738 * while fading in the new image. 739 * 740 * @param {HTMLCanvasElement} canvas New content canvas. 741 * @param {Rect} imageCropRect The crop rectangle in image coordinates. 742 * @return {number} Animation duration. 743 */ 744 ImageView.prototype.animateAndReplace = function(canvas, imageCropRect) { 745 var deviceFullRect = this.viewport_.getDeviceClipped(); 746 var oldScale = this.viewport_.getScale(); 747 748 var oldScreenImage = this.screenImage_; 749 this.replaceContent_(canvas); 750 var newScreenImage = this.screenImage_; 751 752 var deviceCropRect = this.viewport_.screenToDeviceRect( 753 this.viewport_.imageToScreenRect(imageCropRect)); 754 755 var setFade = ImageUtil.setAttribute.bind(null, newScreenImage, 'fade'); 756 setFade(true); 757 oldScreenImage.parentNode.insertBefore(newScreenImage, oldScreenImage); 758 759 var effect = new ImageView.Effect.Zoom(deviceCropRect, deviceFullRect); 760 // Animate to the transformed state. 761 this.setTransform(oldScreenImage, effect); 762 763 setTimeout(setFade.bind(null, false), 0); 764 765 setTimeout(function() { 766 if (oldScreenImage.parentNode) 767 oldScreenImage.parentNode.removeChild(oldScreenImage); 768 }, effect.getSafeInterval()); 769 770 return effect.getSafeInterval(); 771 }; 772 773 774 /** 775 * Generic cache with a limited capacity and LRU eviction. 776 * 777 * @param {number} capacity Maximum number of cached item. 778 * @constructor 779 */ 780 ImageView.Cache = function(capacity) { 781 this.capacity_ = capacity; 782 this.map_ = {}; 783 this.order_ = []; 784 }; 785 786 /** 787 * Fetch the item from the cache. 788 * 789 * @param {string} id The item ID. 790 * @return {Object} The cached item. 791 */ 792 ImageView.Cache.prototype.getItem = function(id) { return this.map_[id] }; 793 794 /** 795 * Put the item into the cache. 796 * @param {string} id The item ID. 797 * @param {Object} item The item object. 798 * @param {boolean=} opt_keepLRU True if the LRU order should not be modified. 799 */ 800 ImageView.Cache.prototype.putItem = function(id, item, opt_keepLRU) { 801 var pos = this.order_.indexOf(id); 802 803 if ((pos >= 0) != (id in this.map_)) 804 throw new Error('Inconsistent cache state'); 805 806 if (id in this.map_) { 807 if (!opt_keepLRU) { 808 // Move to the end (most recently used). 809 this.order_.splice(pos, 1); 810 this.order_.push(id); 811 } 812 } else { 813 this.evictLRU(); 814 this.order_.push(id); 815 } 816 817 if ((pos >= 0) && (item != this.map_[id])) 818 this.deleteItem_(this.map_[id]); 819 this.map_[id] = item; 820 821 if (this.order_.length > this.capacity_) 822 throw new Error('Exceeded cache capacity'); 823 }; 824 825 /** 826 * Evict the least recently used items. 827 */ 828 ImageView.Cache.prototype.evictLRU = function() { 829 if (this.order_.length == this.capacity_) { 830 var id = this.order_.shift(); 831 this.deleteItem_(this.map_[id]); 832 delete this.map_[id]; 833 } 834 }; 835 836 /** 837 * Change the id of an entry. 838 * @param {string} oldId The old ID. 839 * @param {string} newId The new ID. 840 */ 841 ImageView.Cache.prototype.renameItem = function(oldId, newId) { 842 if (oldId == newId) 843 return; // No need to rename. 844 845 var pos = this.order_.indexOf(oldId); 846 if (pos < 0) 847 return; // Not cached. 848 849 this.order_[pos] = newId; 850 this.map_[newId] = this.map_[oldId]; 851 delete this.map_[oldId]; 852 }; 853 854 /** 855 * Disposes an object. 856 * 857 * @param {Object} item The item object. 858 * @private 859 */ 860 ImageView.Cache.prototype.deleteItem_ = function(item) { 861 // Trick to reduce memory usage without waiting for gc. 862 if (item instanceof HTMLCanvasElement) { 863 // If the canvas is being used somewhere else (eg. displayed on the screen), 864 // it will be cleared. 865 item.width = 0; 866 item.height = 0; 867 } 868 }; 869 870 /* Transition effects */ 871 872 /** 873 * Base class for effects. 874 * 875 * @param {number} duration Duration in ms. 876 * @param {string=} opt_timing CSS transition timing function name. 877 * @constructor 878 */ 879 ImageView.Effect = function(duration, opt_timing) { 880 this.duration_ = duration; 881 this.timing_ = opt_timing || 'linear'; 882 }; 883 884 /** 885 * 886 */ 887 ImageView.Effect.DEFAULT_DURATION = 180; 888 889 /** 890 * 891 */ 892 ImageView.Effect.MARGIN = 100; 893 894 /** 895 * @return {number} Effect duration in ms. 896 */ 897 ImageView.Effect.prototype.getDuration = function() { return this.duration_ }; 898 899 /** 900 * @return {number} Delay in ms since the beginning of the animation after which 901 * it is safe to perform CPU-heavy operations without disrupting the animation. 902 */ 903 ImageView.Effect.prototype.getSafeInterval = function() { 904 return this.getDuration() + ImageView.Effect.MARGIN; 905 }; 906 907 /** 908 * @return {string} CSS transition timing function name. 909 */ 910 ImageView.Effect.prototype.getTiming = function() { return this.timing_ }; 911 912 /** 913 * @param {HTMLCanvasElement|HTMLVideoElement} element Element. 914 * @return {number} Preferred pixel ration to use with this element. 915 * @private 916 */ 917 ImageView.Effect.getPixelRatio_ = function(element) { 918 if (element.constructor.name == 'HTMLCanvasElement') 919 return Viewport.getDevicePixelRatio(); 920 else 921 return 1; 922 }; 923 924 /** 925 * Default effect. It is not a no-op as it needs to adjust a canvas scale 926 * for devicePixelRatio. 927 * 928 * @constructor 929 */ 930 ImageView.Effect.None = function() { 931 ImageView.Effect.call(this, 0); 932 }; 933 934 /** 935 * Inherits from ImageView.Effect. 936 */ 937 ImageView.Effect.None.prototype = { __proto__: ImageView.Effect.prototype }; 938 939 /** 940 * @param {HTMLCanvasElement|HTMLVideoElement} element Element. 941 * @return {string} Transform string. 942 */ 943 ImageView.Effect.None.prototype.transform = function(element) { 944 var ratio = ImageView.Effect.getPixelRatio_(element); 945 return 'scale(' + (1 / ratio) + ')'; 946 }; 947 948 /** 949 * Slide effect. 950 * 951 * @param {number} direction -1 for left, 1 for right. 952 * @param {boolean=} opt_slow True if slow (as in slideshow). 953 * @constructor 954 */ 955 ImageView.Effect.Slide = function Slide(direction, opt_slow) { 956 ImageView.Effect.call(this, 957 opt_slow ? 800 : ImageView.Effect.DEFAULT_DURATION, 'ease-in-out'); 958 this.direction_ = direction; 959 this.slow_ = opt_slow; 960 this.shift_ = opt_slow ? 100 : 40; 961 if (this.direction_ < 0) this.shift_ = -this.shift_; 962 }; 963 964 /** 965 * Inherits from ImageView.Effect. 966 */ 967 ImageView.Effect.Slide.prototype = { __proto__: ImageView.Effect.prototype }; 968 969 /** 970 * @return {ImageView.Effect.Slide} Reverse Slide effect. 971 */ 972 ImageView.Effect.Slide.prototype.getReverse = function() { 973 return new ImageView.Effect.Slide(-this.direction_, this.slow_); 974 }; 975 976 /** 977 * @param {HTMLCanvasElement|HTMLVideoElement} element Element. 978 * @return {string} Transform string. 979 */ 980 ImageView.Effect.Slide.prototype.transform = function(element) { 981 var ratio = ImageView.Effect.getPixelRatio_(element); 982 return 'scale(' + (1 / ratio) + ') translate(' + this.shift_ + 'px, 0px)'; 983 }; 984 985 /** 986 * Zoom effect. 987 * 988 * Animates the original rectangle to the target rectangle. Both parameters 989 * should be given in device coordinates (accounting for devicePixelRatio). 990 * 991 * @param {Rect} deviceTargetRect Target rectangle. 992 * @param {Rect=} opt_deviceOriginalRect Original rectangle. If omitted, 993 * the full viewport will be used at the time of |transform| call. 994 * @param {number=} opt_duration Duration in ms. 995 * @constructor 996 */ 997 ImageView.Effect.Zoom = function( 998 deviceTargetRect, opt_deviceOriginalRect, opt_duration) { 999 ImageView.Effect.call(this, 1000 opt_duration || ImageView.Effect.DEFAULT_DURATION); 1001 this.target_ = deviceTargetRect; 1002 this.original_ = opt_deviceOriginalRect; 1003 }; 1004 1005 /** 1006 * Inherits from ImageView.Effect. 1007 */ 1008 ImageView.Effect.Zoom.prototype = { __proto__: ImageView.Effect.prototype }; 1009 1010 /** 1011 * @param {HTMLCanvasElement|HTMLVideoElement} element Element. 1012 * @param {Viewport} viewport Viewport. 1013 * @return {string} Transform string. 1014 */ 1015 ImageView.Effect.Zoom.prototype.transform = function(element, viewport) { 1016 if (!this.original_) 1017 this.original_ = viewport.getDeviceClipped(); 1018 1019 var ratio = ImageView.Effect.getPixelRatio_(element); 1020 1021 var dx = (this.target_.left + this.target_.width / 2) - 1022 (this.original_.left + this.original_.width / 2); 1023 var dy = (this.target_.top + this.target_.height / 2) - 1024 (this.original_.top + this.original_.height / 2); 1025 1026 var scaleX = this.target_.width / this.original_.width; 1027 var scaleY = this.target_.height / this.original_.height; 1028 1029 return 'translate(' + (dx / ratio) + 'px,' + (dy / ratio) + 'px) ' + 1030 'scaleX(' + (scaleX / ratio) + ') scaleY(' + (scaleY / ratio) + ')'; 1031 }; 1032 1033 /** 1034 * Rotate effect. 1035 * 1036 * @param {number} scale Scale. 1037 * @param {number} rotate90 Rotation in 90 degrees increments. 1038 * @constructor 1039 */ 1040 ImageView.Effect.Rotate = function(scale, rotate90) { 1041 ImageView.Effect.call(this, ImageView.Effect.DEFAULT_DURATION); 1042 this.scale_ = scale; 1043 this.rotate90_ = rotate90; 1044 }; 1045 1046 /** 1047 * Inherits from ImageView.Effect. 1048 */ 1049 ImageView.Effect.Rotate.prototype = { __proto__: ImageView.Effect.prototype }; 1050 1051 /** 1052 * @param {HTMLCanvasElement|HTMLVideoElement} element Element. 1053 * @return {string} Transform string. 1054 */ 1055 ImageView.Effect.Rotate.prototype.transform = function(element) { 1056 var ratio = ImageView.Effect.getPixelRatio_(element); 1057 return 'rotate(' + (this.rotate90_ * 90) + 'deg) ' + 1058 'scale(' + (this.scale_ / ratio) + ')'; 1059 }; 1060