Upstream version 10.38.222.0
[platform/framework/web/crosswalk.git] / src / ui / file_manager / file_manager / foreground / js / image_editor / image_view.js
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 };