Home | History | Annotate | Download | only in image_editor
      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