• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 */
15function 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.get(entry, 'fetchedMedia', function(fetchedMedia) {
51      callback(fetchedMedia.imageTransform);
52    });
53  };
54}
55
56/**
57 * Duration of transition between modes in ms.
58 */
59ImageView.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 */
65ImageView.FAST_SCROLL_INTERVAL = 300;
66
67/**
68 * Image load type: full resolution image loaded from cache.
69 */
70ImageView.LOAD_TYPE_CACHED_FULL = 0;
71
72/**
73 * Image load type: screen resolution preview loaded from cache.
74 */
75ImageView.LOAD_TYPE_CACHED_SCREEN = 1;
76
77/**
78 * Image load type: image read from file.
79 */
80ImageView.LOAD_TYPE_IMAGE_FILE = 2;
81
82/**
83 * Image load type: video loaded.
84 */
85ImageView.LOAD_TYPE_VIDEO_FILE = 3;
86
87/**
88 * Image load type: error occurred.
89 */
90ImageView.LOAD_TYPE_ERROR = 4;
91
92/**
93 * Image load type: the file contents is not available offline.
94 */
95ImageView.LOAD_TYPE_OFFLINE = 5;
96
97/**
98 * The total number of load types.
99 */
100ImageView.LOAD_TYPE_TOTAL = 6;
101
102ImageView.prototype = {__proto__: ImageBuffer.Overlay.prototype};
103
104/**
105 * Draws below overlays with the default zIndex.
106 * @return {number} Z-index.
107 */
108ImageView.prototype.getZIndex = function() { return -1 };
109
110/**
111 * Draws the image on screen.
112 */
113ImageView.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 */
145ImageView.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 */
159ImageView.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 */
172ImageView.prototype.getCacheGeneration = function() {
173  return this.contentGeneration_;
174};
175
176/**
177 * Invalidates the caches to force redrawing the screen canvas.
178 */
179ImageView.prototype.invalidateCaches = function() {
180  this.contentGeneration_++;
181};
182
183/**
184 * @return {HTMLCanvasElement} The content canvas element.
185 */
186ImageView.prototype.getCanvas = function() { return this.contentCanvas_ };
187
188/**
189 * @return {boolean} True if the a valid image is currently loaded.
190 */
191ImageView.prototype.hasValidImage = function() {
192  return !this.preview_ && this.contentCanvas_ && this.contentCanvas_.width;
193};
194
195/**
196 * @return {HTMLVideoElement} The video element.
197 */
198ImageView.prototype.getVideo = function() { return this.videoElement_ };
199
200/**
201 * @return {HTMLCanvasElement} The cached thumbnail image.
202 */
203ImageView.prototype.getThumbnail = function() { return this.thumbnailCanvas_ };
204
205/**
206 * @return {number} The content revision number.
207 */
208ImageView.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 */
220ImageView.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 */
242ImageView.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 */
254ImageView.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 */
274ImageView.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 */
282ImageView.prototype.isLoading = function() {
283  return this.imageLoader_.isBusy();
284};
285
286/**
287 * Cancels the current image loading operation. The callbacks will be ignored.
288 */
289ImageView.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 */
307ImageView.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          metadata.thumbnail.url,
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        !(imageWidth && imageHeight &&
387          ImageUtil.ImageLoader.isTooLarge(imageWidth, imageHeight))) {
388      // Only show thumbnails if there is no effect or the effect is Slide.
389      // Also no thumbnail if the image is too large to be loaded.
390      var thumbnailLoader = new ThumbnailLoader(
391          metadata.thumbnail.url,
392          ThumbnailLoader.LoaderType.CANVAS,
393          metadata);
394      thumbnailLoader.loadDetachedImage(function(success) {
395        displayThumbnail(ImageView.LOAD_TYPE_IMAGE_FILE,
396                         success ? thumbnailLoader.getImage() : null);
397      });
398    } else {
399      loadMainImage(ImageView.LOAD_TYPE_IMAGE_FILE, entry,
400          false /* no preview*/, 0 /* delay */);
401    }
402  }
403
404  function displayThumbnail(loadType, canvas) {
405    if (canvas) {
406      self.replace(
407          canvas,
408          effect,
409          metadata.media.width || metadata.drive.imageWidth,
410          metadata.media.height || metadata.drive.imageHeight,
411          true /* preview */);
412      if (displayCallback) displayCallback();
413    }
414    loadMainImage(loadType, entry, !!canvas,
415        (effect && canvas) ? effect.getSafeInterval() : 0);
416  }
417
418  function loadMainImage(loadType, contentEntry, previewShown, delay) {
419    if (self.prefetchLoader_.isLoading(contentEntry)) {
420      // The image we need is already being prefetched. Initiating another load
421      // would be a waste. Hijack the load instead by overriding the callback.
422      self.prefetchLoader_.setCallback(
423          displayMainImage.bind(null, loadType, previewShown));
424
425      // Swap the loaders so that the self.isLoading works correctly.
426      var temp = self.prefetchLoader_;
427      self.prefetchLoader_ = self.imageLoader_;
428      self.imageLoader_ = temp;
429      return;
430    }
431    self.prefetchLoader_.cancel();  // The prefetch was doing something useless.
432
433    self.imageLoader_.load(
434        contentEntry,
435        self.localImageTransformFetcher_,
436        displayMainImage.bind(null, loadType, previewShown),
437        delay);
438  }
439
440  function displayMainImage(loadType, previewShown, content, opt_error) {
441    if (opt_error)
442      loadType = ImageView.LOAD_TYPE_ERROR;
443
444    // If we already displayed the preview we should not replace the content if:
445    //   1. The full content failed to load.
446    //     or
447    //   2. We are loading a video (because the full video is displayed in the
448    //      same HTML element as the preview).
449    var animationDuration = 0;
450    if (!(previewShown &&
451        (loadType === ImageView.LOAD_TYPE_ERROR ||
452         loadType === ImageView.LOAD_TYPE_VIDEO_FILE))) {
453      var replaceEffect = previewShown ? null : effect;
454      animationDuration = replaceEffect ? replaceEffect.getSafeInterval() : 0;
455      self.replace(content, replaceEffect);
456      if (!previewShown && displayCallback) displayCallback();
457    }
458
459    if (loadType !== ImageView.LOAD_TYPE_ERROR &&
460        loadType !== ImageView.LOAD_TYPE_CACHED_SCREEN) {
461      ImageUtil.metrics.recordInterval(ImageUtil.getMetricName('DisplayTime'));
462    }
463    ImageUtil.metrics.recordEnum(ImageUtil.getMetricName('LoadMode'),
464        loadType, ImageView.LOAD_TYPE_TOTAL);
465
466    if (loadType === ImageView.LOAD_TYPE_ERROR &&
467        !navigator.onLine && metadata.streaming) {
468      // |streaming| is set only when the file is not locally cached.
469      loadType = ImageView.LOAD_TYPE_OFFLINE;
470    }
471    if (loadCallback) loadCallback(loadType, animationDuration, opt_error);
472  }
473};
474
475/**
476 * Prefetches an image.
477 * @param {FileEntry} entry The image entry.
478 * @param {number} delay Image load delay in ms.
479 */
480ImageView.prototype.prefetch = function(entry, delay) {
481  var self = this;
482  function prefetchDone(canvas) {
483    if (canvas.width)
484      self.contentCache_.putItem(entry, canvas);
485  }
486
487  var cached = this.contentCache_.getItem(entry);
488  if (cached) {
489    prefetchDone(cached);
490  } else if (FileType.getMediaType(entry) === 'image') {
491    // Evict the LRU item before we allocate the new canvas to avoid unneeded
492    // strain on memory.
493    this.contentCache_.evictLRU();
494
495    this.prefetchLoader_.load(
496        entry,
497        this.localImageTransformFetcher_,
498        prefetchDone,
499        delay);
500  }
501};
502
503/**
504 * Renames the current image.
505 * @param {FileEntry} newEntry The new image Entry.
506 */
507ImageView.prototype.changeEntry = function(newEntry) {
508  this.contentCache_.renameItem(this.contentEntry_, newEntry);
509  this.screenCache_.renameItem(this.contentEntry_, newEntry);
510  this.contentEntry_ = newEntry;
511};
512
513/**
514 * Unloads content.
515 * @param {Rect} zoomToRect Target rectangle for zoom-out-effect.
516 */
517ImageView.prototype.unload = function(zoomToRect) {
518  if (this.unloadTimer_) {
519    clearTimeout(this.unloadTimer_);
520    this.unloadTimer_ = null;
521  }
522  if (zoomToRect && this.screenImage_) {
523    var effect = this.createZoomEffect(zoomToRect);
524    this.setTransform(this.screenImage_, effect);
525    this.screenImage_.setAttribute('fade', true);
526    this.unloadTimer_ = setTimeout(function() {
527        this.unloadTimer_ = null;
528        this.unload(null /* force unload */);
529      }.bind(this),
530      effect.getSafeInterval());
531    return;
532  }
533  this.container_.textContent = '';
534  this.contentCanvas_ = null;
535  this.screenImage_ = null;
536  this.videoElement_ = null;
537};
538
539/**
540 * @param {HTMLCanvasElement|HTMLVideoElement} content The image element.
541 * @param {number=} opt_width Image width.
542 * @param {number=} opt_height Image height.
543 * @param {boolean=} opt_preview True if the image is a preview (not full res).
544 * @private
545 */
546ImageView.prototype.replaceContent_ = function(
547    content, opt_width, opt_height, opt_preview) {
548
549  if (this.contentCanvas_ && this.contentCanvas_.parentNode === this.container_)
550    this.container_.removeChild(this.contentCanvas_);
551
552  if (content.constructor.name === 'HTMLVideoElement') {
553    this.contentCanvas_ = null;
554    this.videoElement_ = content;
555    this.screenImage_ = content;
556    this.screenImage_.className = 'image';
557    this.container_.appendChild(this.screenImage_);
558    this.videoElement_.play();
559    return;
560  }
561
562  this.screenImage_ = this.document_.createElement('canvas');
563  this.screenImage_.className = 'image';
564
565  this.videoElement_ = null;
566  this.contentCanvas_ = content;
567  this.invalidateCaches();
568  this.viewport_.setImageSize(
569      opt_width || this.contentCanvas_.width,
570      opt_height || this.contentCanvas_.height);
571  this.viewport_.fitImage();
572  this.viewport_.update();
573  this.draw();
574
575  this.container_.appendChild(this.screenImage_);
576
577  this.preview_ = opt_preview;
578  // If this is not a thumbnail, cache the content and the screen-scale image.
579  if (this.hasValidImage()) {
580    // Insert the full resolution canvas into DOM so that it can be printed.
581    this.container_.appendChild(this.contentCanvas_);
582    this.contentCanvas_.classList.add('fullres');
583
584    this.contentCache_.putItem(this.contentEntry_, this.contentCanvas_, true);
585    this.screenCache_.putItem(this.contentEntry_, this.screenImage_);
586
587    // TODO(kaznacheev): It is better to pass screenImage_ as it is usually
588    // much smaller than contentCanvas_ and still contains the entire image.
589    // Once we implement zoom/pan we should pass contentCanvas_ instead.
590    this.updateThumbnail_(this.screenImage_);
591
592    this.contentRevision_++;
593    for (var i = 0; i !== this.contentCallbacks_.length; i++) {
594      try {
595        this.contentCallbacks_[i]();
596      } catch (e) {
597        console.error(e);
598      }
599    }
600  }
601};
602
603/**
604 * Adds a listener for content changes.
605 * @param {function} callback Callback.
606 */
607ImageView.prototype.addContentCallback = function(callback) {
608  this.contentCallbacks_.push(callback);
609};
610
611/**
612 * Updates the cached thumbnail image.
613 *
614 * @param {HTMLCanvasElement} canvas The source canvas.
615 * @private
616 */
617ImageView.prototype.updateThumbnail_ = function(canvas) {
618  ImageUtil.trace.resetTimer('thumb');
619  var pixelCount = 10000;
620  var downScale =
621      Math.max(1, Math.sqrt(canvas.width * canvas.height / pixelCount));
622
623  this.thumbnailCanvas_ = canvas.ownerDocument.createElement('canvas');
624  this.thumbnailCanvas_.width = Math.round(canvas.width / downScale);
625  this.thumbnailCanvas_.height = Math.round(canvas.height / downScale);
626  Rect.drawImage(this.thumbnailCanvas_.getContext('2d'), canvas);
627  ImageUtil.trace.reportTimer('thumb');
628};
629
630/**
631 * Replaces the displayed image, possibly with slide-in animation.
632 *
633 * @param {HTMLCanvasElement|HTMLVideoElement} content The image element.
634 * @param {Object=} opt_effect Transition effect object.
635 * @param {number=} opt_width Image width.
636 * @param {number=} opt_height Image height.
637 * @param {boolean=} opt_preview True if the image is a preview (not full res).
638 */
639ImageView.prototype.replace = function(
640    content, opt_effect, opt_width, opt_height, opt_preview) {
641  var oldScreenImage = this.screenImage_;
642
643  this.replaceContent_(content, opt_width, opt_height, opt_preview);
644  if (!opt_effect) {
645    if (oldScreenImage)
646      oldScreenImage.parentNode.removeChild(oldScreenImage);
647    return;
648  }
649
650  var newScreenImage = this.screenImage_;
651
652  if (oldScreenImage)
653    ImageUtil.setAttribute(newScreenImage, 'fade', true);
654  this.setTransform(newScreenImage, opt_effect, 0 /* instant */);
655
656  setTimeout(function() {
657    this.setTransform(newScreenImage, null,
658        opt_effect && opt_effect.getDuration());
659    if (oldScreenImage) {
660      ImageUtil.setAttribute(newScreenImage, 'fade', false);
661      ImageUtil.setAttribute(oldScreenImage, 'fade', true);
662      console.assert(opt_effect.getReverse, 'Cannot revert an effect.');
663      var reverse = opt_effect.getReverse();
664      this.setTransform(oldScreenImage, reverse);
665      setTimeout(function() {
666        if (oldScreenImage.parentNode)
667          oldScreenImage.parentNode.removeChild(oldScreenImage);
668      }, reverse.getSafeInterval());
669    }
670  }.bind(this), 0);
671};
672
673/**
674 * @param {HTMLCanvasElement|HTMLVideoElement} element The element to transform.
675 * @param {ImageView.Effect=} opt_effect The effect to apply.
676 * @param {number=} opt_duration Transition duration.
677 */
678ImageView.prototype.setTransform = function(element, opt_effect, opt_duration) {
679  if (!opt_effect)
680    opt_effect = new ImageView.Effect.None();
681  if (typeof opt_duration !== 'number')
682    opt_duration = opt_effect.getDuration();
683  element.style.webkitTransitionDuration = opt_duration + 'ms';
684  element.style.webkitTransitionTimingFunction = opt_effect.getTiming();
685  element.style.webkitTransform = opt_effect.transform(element, this.viewport_);
686};
687
688/**
689 * @param {Rect} screenRect Target rectangle in screen coordinates.
690 * @return {ImageView.Effect.Zoom} Zoom effect object.
691 */
692ImageView.prototype.createZoomEffect = function(screenRect) {
693  return new ImageView.Effect.Zoom(
694      this.viewport_.screenToDeviceRect(screenRect),
695      null /* use viewport */,
696      ImageView.MODE_TRANSITION_DURATION);
697};
698
699/**
700 * Visualizes crop or rotate operation. Hide the old image instantly, animate
701 * the new image to visualize the operation.
702 *
703 * @param {HTMLCanvasElement} canvas New content canvas.
704 * @param {Rect} imageCropRect The crop rectangle in image coordinates.
705 *                             Null for rotation operations.
706 * @param {number} rotate90 Rotation angle in 90 degree increments.
707 * @return {number} Animation duration.
708 */
709ImageView.prototype.replaceAndAnimate = function(
710    canvas, imageCropRect, rotate90) {
711  var oldScale = this.viewport_.getScale();
712  var deviceCropRect = imageCropRect && this.viewport_.screenToDeviceRect(
713        this.viewport_.imageToScreenRect(imageCropRect));
714
715  var oldScreenImage = this.screenImage_;
716  this.replaceContent_(canvas);
717  var newScreenImage = this.screenImage_;
718
719  // Display the new canvas, initially transformed.
720  var deviceFullRect = this.viewport_.getDeviceClipped();
721
722  var effect = rotate90 ?
723      new ImageView.Effect.Rotate(
724          oldScale / this.viewport_.getScale(), -rotate90) :
725      new ImageView.Effect.Zoom(deviceCropRect, deviceFullRect);
726
727  this.setTransform(newScreenImage, effect, 0 /* instant */);
728
729  oldScreenImage.parentNode.appendChild(newScreenImage);
730  oldScreenImage.parentNode.removeChild(oldScreenImage);
731
732  // Let the layout fire, then animate back to non-transformed state.
733  setTimeout(
734      this.setTransform.bind(
735          this, newScreenImage, null, effect.getDuration()),
736      0);
737
738  return effect.getSafeInterval();
739};
740
741/**
742 * Visualizes "undo crop". Shrink the current image to the given crop rectangle
743 * while fading in the new image.
744 *
745 * @param {HTMLCanvasElement} canvas New content canvas.
746 * @param {Rect} imageCropRect The crop rectangle in image coordinates.
747 * @return {number} Animation duration.
748 */
749ImageView.prototype.animateAndReplace = function(canvas, imageCropRect) {
750  var deviceFullRect = this.viewport_.getDeviceClipped();
751  var oldScale = this.viewport_.getScale();
752
753  var oldScreenImage = this.screenImage_;
754  this.replaceContent_(canvas);
755  var newScreenImage = this.screenImage_;
756
757  var deviceCropRect = this.viewport_.screenToDeviceRect(
758        this.viewport_.imageToScreenRect(imageCropRect));
759
760  var setFade = ImageUtil.setAttribute.bind(null, newScreenImage, 'fade');
761  setFade(true);
762  oldScreenImage.parentNode.insertBefore(newScreenImage, oldScreenImage);
763
764  var effect = new ImageView.Effect.Zoom(deviceCropRect, deviceFullRect);
765  // Animate to the transformed state.
766  this.setTransform(oldScreenImage, effect);
767
768  setTimeout(setFade.bind(null, false), 0);
769
770  setTimeout(function() {
771    if (oldScreenImage.parentNode)
772      oldScreenImage.parentNode.removeChild(oldScreenImage);
773  }, effect.getSafeInterval());
774
775  return effect.getSafeInterval();
776};
777
778
779/**
780 * Generic cache with a limited capacity and LRU eviction.
781 * @param {number} capacity Maximum number of cached item.
782 * @constructor
783 */
784ImageView.Cache = function(capacity) {
785  this.capacity_ = capacity;
786  this.map_ = {};
787  this.order_ = [];
788};
789
790/**
791 * Fetches the item from the cache.
792 * @param {FileEntry} entry The entry.
793 * @return {Object} The cached item.
794 */
795ImageView.Cache.prototype.getItem = function(entry) {
796  return this.map_[entry.toURL()];
797};
798
799/**
800 * Puts the item into the cache.
801 *
802 * @param {FileEntry} entry The entry.
803 * @param {Object} item The item object.
804 * @param {boolean=} opt_keepLRU True if the LRU order should not be modified.
805 */
806ImageView.Cache.prototype.putItem = function(entry, item, opt_keepLRU) {
807  var pos = this.order_.indexOf(entry.toURL());
808
809  if ((pos >= 0) !== (entry.toURL() in this.map_))
810    throw new Error('Inconsistent cache state');
811
812  if (entry.toURL() in this.map_) {
813    if (!opt_keepLRU) {
814      // Move to the end (most recently used).
815      this.order_.splice(pos, 1);
816      this.order_.push(entry.toURL());
817    }
818  } else {
819    this.evictLRU();
820    this.order_.push(entry.toURL());
821  }
822
823  if ((pos >= 0) && (item !== this.map_[entry.toURL()]))
824    this.deleteItem_(this.map_[entry.toURL()]);
825  this.map_[entry.toURL()] = item;
826
827  if (this.order_.length > this.capacity_)
828    throw new Error('Exceeded cache capacity');
829};
830
831/**
832 * Evicts the least recently used items.
833 */
834ImageView.Cache.prototype.evictLRU = function() {
835  if (this.order_.length === this.capacity_) {
836    var url = this.order_.shift();
837    this.deleteItem_(this.map_[url]);
838    delete this.map_[url];
839  }
840};
841
842/**
843 * Changes the Entry.
844 * @param {FileEntry} oldEntry The old Entry.
845 * @param {FileEntry} newEntry The new Entry.
846 */
847ImageView.Cache.prototype.renameItem = function(oldEntry, newEntry) {
848  if (util.isSameEntry(oldEntry, newEntry))
849    return;  // No need to rename.
850
851  var pos = this.order_.indexOf(oldEntry.toURL());
852  if (pos < 0)
853    return;  // Not cached.
854
855  this.order_[pos] = newEntry.toURL();
856  this.map_[newEntry.toURL()] = this.map_[oldEntry.toURL()];
857  delete this.map_[oldEntry.toURL()];
858};
859
860/**
861 * Disposes an object.
862 *
863 * @param {Object} item The item object.
864 * @private
865 */
866ImageView.Cache.prototype.deleteItem_ = function(item) {
867  // Trick to reduce memory usage without waiting for gc.
868  if (item instanceof HTMLCanvasElement) {
869    // If the canvas is being used somewhere else (eg. displayed on the screen),
870    // it will be cleared.
871    item.width = 0;
872    item.height = 0;
873  }
874};
875
876/* Transition effects */
877
878/**
879 * Base class for effects.
880 *
881 * @param {number} duration Duration in ms.
882 * @param {string=} opt_timing CSS transition timing function name.
883 * @constructor
884 */
885ImageView.Effect = function(duration, opt_timing) {
886  this.duration_ = duration;
887  this.timing_ = opt_timing || 'linear';
888};
889
890/**
891 *
892 */
893ImageView.Effect.DEFAULT_DURATION = 180;
894
895/**
896 *
897 */
898ImageView.Effect.MARGIN = 100;
899
900/**
901 * @return {number} Effect duration in ms.
902 */
903ImageView.Effect.prototype.getDuration = function() { return this.duration_ };
904
905/**
906 * @return {number} Delay in ms since the beginning of the animation after which
907 * it is safe to perform CPU-heavy operations without disrupting the animation.
908 */
909ImageView.Effect.prototype.getSafeInterval = function() {
910  return this.getDuration() + ImageView.Effect.MARGIN;
911};
912
913/**
914 * @return {string} CSS transition timing function name.
915 */
916ImageView.Effect.prototype.getTiming = function() { return this.timing_ };
917
918/**
919 * @param {HTMLCanvasElement|HTMLVideoElement} element Element.
920 * @return {number} Preferred pixel ration to use with this element.
921 * @private
922 */
923ImageView.Effect.getPixelRatio_ = function(element) {
924  if (element.constructor.name === 'HTMLCanvasElement')
925    return Viewport.getDevicePixelRatio();
926  else
927    return 1;
928};
929
930/**
931 * Default effect. It is not a no-op as it needs to adjust a canvas scale
932 * for devicePixelRatio.
933 *
934 * @constructor
935 */
936ImageView.Effect.None = function() {
937  ImageView.Effect.call(this, 0);
938};
939
940/**
941 * Inherits from ImageView.Effect.
942 */
943ImageView.Effect.None.prototype = { __proto__: ImageView.Effect.prototype };
944
945/**
946 * @param {HTMLCanvasElement|HTMLVideoElement} element Element.
947 * @return {string} Transform string.
948 */
949ImageView.Effect.None.prototype.transform = function(element) {
950  var ratio = ImageView.Effect.getPixelRatio_(element);
951  return 'scale(' + (1 / ratio) + ')';
952};
953
954/**
955 * Slide effect.
956 *
957 * @param {number} direction -1 for left, 1 for right.
958 * @param {boolean=} opt_slow True if slow (as in slideshow).
959 * @constructor
960 */
961ImageView.Effect.Slide = function Slide(direction, opt_slow) {
962  ImageView.Effect.call(this,
963      opt_slow ? 800 : ImageView.Effect.DEFAULT_DURATION, 'ease-in-out');
964  this.direction_ = direction;
965  this.slow_ = opt_slow;
966  this.shift_ = opt_slow ? 100 : 40;
967  if (this.direction_ < 0) this.shift_ = -this.shift_;
968};
969
970/**
971 * Inherits from ImageView.Effect.
972 */
973ImageView.Effect.Slide.prototype = { __proto__: ImageView.Effect.prototype };
974
975/**
976 * @return {ImageView.Effect.Slide} Reverse Slide effect.
977 */
978ImageView.Effect.Slide.prototype.getReverse = function() {
979  return new ImageView.Effect.Slide(-this.direction_, this.slow_);
980};
981
982/**
983 * @param {HTMLCanvasElement|HTMLVideoElement} element Element.
984 * @return {string} Transform string.
985 */
986ImageView.Effect.Slide.prototype.transform = function(element) {
987  var ratio = ImageView.Effect.getPixelRatio_(element);
988  return 'scale(' + (1 / ratio) + ') translate(' + this.shift_ + 'px, 0px)';
989};
990
991/**
992 * Zoom effect.
993 *
994 * Animates the original rectangle to the target rectangle. Both parameters
995 * should be given in device coordinates (accounting for devicePixelRatio).
996 *
997 * @param {Rect} deviceTargetRect Target rectangle.
998 * @param {Rect=} opt_deviceOriginalRect Original rectangle. If omitted,
999 *     the full viewport will be used at the time of |transform| call.
1000 * @param {number=} opt_duration Duration in ms.
1001 * @constructor
1002 */
1003ImageView.Effect.Zoom = function(
1004    deviceTargetRect, opt_deviceOriginalRect, opt_duration) {
1005  ImageView.Effect.call(this,
1006      opt_duration || ImageView.Effect.DEFAULT_DURATION);
1007  this.target_ = deviceTargetRect;
1008  this.original_ = opt_deviceOriginalRect;
1009};
1010
1011/**
1012 * Inherits from ImageView.Effect.
1013 */
1014ImageView.Effect.Zoom.prototype = { __proto__: ImageView.Effect.prototype };
1015
1016/**
1017 * @param {HTMLCanvasElement|HTMLVideoElement} element Element.
1018 * @param {Viewport} viewport Viewport.
1019 * @return {string} Transform string.
1020 */
1021ImageView.Effect.Zoom.prototype.transform = function(element, viewport) {
1022  if (!this.original_)
1023    this.original_ = viewport.getDeviceClipped();
1024
1025  var ratio = ImageView.Effect.getPixelRatio_(element);
1026
1027  var dx = (this.target_.left + this.target_.width / 2) -
1028           (this.original_.left + this.original_.width / 2);
1029  var dy = (this.target_.top + this.target_.height / 2) -
1030           (this.original_.top + this.original_.height / 2);
1031
1032  var scaleX = this.target_.width / this.original_.width;
1033  var scaleY = this.target_.height / this.original_.height;
1034
1035  return 'translate(' + (dx / ratio) + 'px,' + (dy / ratio) + 'px) ' +
1036    'scaleX(' + (scaleX / ratio) + ') scaleY(' + (scaleY / ratio) + ')';
1037};
1038
1039/**
1040 * Rotate effect.
1041 *
1042 * @param {number} scale Scale.
1043 * @param {number} rotate90 Rotation in 90 degrees increments.
1044 * @constructor
1045 */
1046ImageView.Effect.Rotate = function(scale, rotate90) {
1047  ImageView.Effect.call(this, ImageView.Effect.DEFAULT_DURATION);
1048  this.scale_ = scale;
1049  this.rotate90_ = rotate90;
1050};
1051
1052/**
1053 * Inherits from ImageView.Effect.
1054 */
1055ImageView.Effect.Rotate.prototype = { __proto__: ImageView.Effect.prototype };
1056
1057/**
1058 * @param {HTMLCanvasElement|HTMLVideoElement} element Element.
1059 * @return {string} Transform string.
1060 */
1061ImageView.Effect.Rotate.prototype.transform = function(element) {
1062  var ratio = ImageView.Effect.getPixelRatio_(element);
1063  return 'rotate(' + (this.rotate90_ * 90) + 'deg) ' +
1064         'scale(' + (this.scale_ / ratio) + ')';
1065};
1066