Upstream version 7.35.144.0
[platform/framework/web/crosswalk.git] / src / chrome / browser / resources / file_manager / foreground / js / photo / slide_mode.js
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  * Slide mode displays a single image and has a set of controls to navigate
9  * between the images and to edit an image.
10  *
11  * TODO(kaznacheev): Introduce a parameter object.
12  *
13  * @param {Element} container Main container element.
14  * @param {Element} content Content container element.
15  * @param {Element} toolbar Toolbar element.
16  * @param {ImageEditor.Prompt} prompt Prompt.
17  * @param {cr.ui.ArrayDataModel} dataModel Data model.
18  * @param {cr.ui.ListSelectionModel} selectionModel Selection model.
19  * @param {Object} context Context.
20  * @param {function(function())} toggleMode Function to toggle the Gallery mode.
21  * @param {function(string):string} displayStringFunction String formatting
22  *     function.
23  * @constructor
24  */
25 function SlideMode(container, content, toolbar, prompt,
26                    dataModel, selectionModel, context,
27                    toggleMode, displayStringFunction) {
28   this.container_ = container;
29   this.document_ = container.ownerDocument;
30   this.content = content;
31   this.toolbar_ = toolbar;
32   this.prompt_ = prompt;
33   this.dataModel_ = dataModel;
34   this.selectionModel_ = selectionModel;
35   this.context_ = context;
36   this.metadataCache_ = context.metadataCache;
37   this.toggleMode_ = toggleMode;
38   this.displayStringFunction_ = displayStringFunction;
39
40   this.onSelectionBound_ = this.onSelection_.bind(this);
41   this.onSpliceBound_ = this.onSplice_.bind(this);
42   this.onContentBound_ = this.onContentChange_.bind(this);
43
44   // Unique numeric key, incremented per each load attempt used to discard
45   // old attempts. This can happen especially when changing selection fast or
46   // Internet connection is slow.
47   this.currentUniqueKey_ = 0;
48
49   this.initListeners_();
50   this.initDom_();
51 }
52
53 /**
54  * SlideMode extends cr.EventTarget.
55  */
56 SlideMode.prototype.__proto__ = cr.EventTarget.prototype;
57
58 /**
59  * List of available editor modes.
60  * @type {Array.<ImageEditor.Mode>}
61  */
62 SlideMode.editorModes = [
63   new ImageEditor.Mode.InstantAutofix(),
64   new ImageEditor.Mode.Crop(),
65   new ImageEditor.Mode.Exposure(),
66   new ImageEditor.Mode.OneClick(
67       'rotate_left', 'GALLERY_ROTATE_LEFT', new Command.Rotate(-1)),
68   new ImageEditor.Mode.OneClick(
69       'rotate_right', 'GALLERY_ROTATE_RIGHT', new Command.Rotate(1))
70 ];
71
72 /**
73  * @return {string} Mode name.
74  */
75 SlideMode.prototype.getName = function() { return 'slide' };
76
77 /**
78  * @return {string} Mode title.
79  */
80 SlideMode.prototype.getTitle = function() { return 'GALLERY_SLIDE' };
81
82 /**
83  * Initialize the listeners.
84  * @private
85  */
86 SlideMode.prototype.initListeners_ = function() {
87   window.addEventListener('resize', this.onResize_.bind(this), false);
88 };
89
90 /**
91  * Initialize the UI.
92  * @private
93  */
94 SlideMode.prototype.initDom_ = function() {
95   // Container for displayed image or video.
96   this.imageContainer_ = util.createChild(
97       this.document_.querySelector('.content'), 'image-container');
98   this.imageContainer_.addEventListener('click', this.onClick_.bind(this));
99
100   this.document_.addEventListener('click', this.onDocumentClick_.bind(this));
101
102   // Overwrite options and info bubble.
103   this.options_ = util.createChild(
104       this.toolbar_.querySelector('.filename-spacer'), 'options');
105
106   this.savedLabel_ = util.createChild(this.options_, 'saved');
107   this.savedLabel_.textContent = this.displayStringFunction_('GALLERY_SAVED');
108
109   var overwriteOriginalBox =
110       util.createChild(this.options_, 'overwrite-original');
111
112   this.overwriteOriginal_ = util.createChild(
113       overwriteOriginalBox, 'common white', 'input');
114   this.overwriteOriginal_.type = 'checkbox';
115   this.overwriteOriginal_.id = 'overwrite-checkbox';
116   util.platform.getPreference(SlideMode.OVERWRITE_KEY, function(value) {
117     // Out-of-the box default is 'true'
118     this.overwriteOriginal_.checked =
119         (typeof value !== 'string' || value === 'true');
120   }.bind(this));
121   this.overwriteOriginal_.addEventListener('click',
122       this.onOverwriteOriginalClick_.bind(this));
123
124   var overwriteLabel = util.createChild(overwriteOriginalBox, '', 'label');
125   overwriteLabel.textContent =
126       this.displayStringFunction_('GALLERY_OVERWRITE_ORIGINAL');
127   overwriteLabel.setAttribute('for', 'overwrite-checkbox');
128
129   this.bubble_ = util.createChild(this.toolbar_, 'bubble');
130   this.bubble_.hidden = true;
131
132   var bubbleContent = util.createChild(this.bubble_);
133   bubbleContent.innerHTML = this.displayStringFunction_(
134       'GALLERY_OVERWRITE_BUBBLE');
135
136   util.createChild(this.bubble_, 'pointer bottom', 'span');
137
138   var bubbleClose = util.createChild(this.bubble_, 'close-x');
139   bubbleClose.addEventListener('click', this.onCloseBubble_.bind(this));
140
141   // Video player controls.
142   this.mediaSpacer_ =
143       util.createChild(this.container_, 'video-controls-spacer');
144   this.mediaToolbar_ = util.createChild(this.mediaSpacer_, 'tool');
145   this.mediaControls_ = new VideoControls(
146       this.mediaToolbar_,
147       this.showErrorBanner_.bind(this, 'GALLERY_VIDEO_ERROR'),
148       this.displayStringFunction_.bind(this),
149       this.toggleFullScreen_.bind(this),
150       this.container_);
151
152   // Ribbon and related controls.
153   this.arrowBox_ = util.createChild(this.container_, 'arrow-box');
154
155   this.arrowLeft_ =
156       util.createChild(this.arrowBox_, 'arrow left tool dimmable');
157   this.arrowLeft_.addEventListener('click',
158       this.advanceManually.bind(this, -1));
159   util.createChild(this.arrowLeft_);
160
161   util.createChild(this.arrowBox_, 'arrow-spacer');
162
163   this.arrowRight_ =
164       util.createChild(this.arrowBox_, 'arrow right tool dimmable');
165   this.arrowRight_.addEventListener('click',
166       this.advanceManually.bind(this, 1));
167   util.createChild(this.arrowRight_);
168
169   this.ribbonSpacer_ = util.createChild(this.toolbar_, 'ribbon-spacer');
170   this.ribbon_ = new Ribbon(this.document_,
171       this.metadataCache_, this.dataModel_, this.selectionModel_);
172   this.ribbonSpacer_.appendChild(this.ribbon_);
173
174   // Error indicator.
175   var errorWrapper = util.createChild(this.container_, 'prompt-wrapper');
176   errorWrapper.setAttribute('pos', 'center');
177
178   this.errorBanner_ = util.createChild(errorWrapper, 'error-banner');
179
180   util.createChild(this.container_, 'spinner');
181
182   var slideShowButton = util.createChild(this.toolbar_,
183       'button slideshow', 'button');
184   slideShowButton.title = this.displayStringFunction_('GALLERY_SLIDESHOW');
185   slideShowButton.addEventListener('click',
186       this.startSlideshow.bind(this, SlideMode.SLIDESHOW_INTERVAL_FIRST));
187
188   var slideShowToolbar =
189       util.createChild(this.container_, 'tool slideshow-toolbar');
190   util.createChild(slideShowToolbar, 'slideshow-play').
191       addEventListener('click', this.toggleSlideshowPause_.bind(this));
192   util.createChild(slideShowToolbar, 'slideshow-end').
193       addEventListener('click', this.stopSlideshow_.bind(this));
194
195   // Editor.
196
197   this.editButton_ = util.createChild(this.toolbar_, 'button edit', 'button');
198   this.editButton_.title = this.displayStringFunction_('GALLERY_EDIT');
199   this.editButton_.setAttribute('disabled', '');  // Disabled by default.
200   this.editButton_.addEventListener('click', this.toggleEditor.bind(this));
201
202   this.printButton_ = util.createChild(this.toolbar_, 'button print', 'button');
203   this.printButton_.title = this.displayStringFunction_('GALLERY_PRINT');
204   this.printButton_.setAttribute('disabled', '');  // Disabled by default.
205   this.printButton_.addEventListener('click', this.print_.bind(this));
206
207   this.editBarSpacer_ = util.createChild(this.toolbar_, 'edit-bar-spacer');
208   this.editBarMain_ = util.createChild(this.editBarSpacer_, 'edit-main');
209
210   this.editBarMode_ = util.createChild(this.container_, 'edit-modal');
211   this.editBarModeWrapper_ = util.createChild(
212       this.editBarMode_, 'edit-modal-wrapper');
213   this.editBarModeWrapper_.hidden = true;
214
215   // Objects supporting image display and editing.
216   this.viewport_ = new Viewport();
217
218   this.imageView_ = new ImageView(
219       this.imageContainer_,
220       this.viewport_,
221       this.metadataCache_);
222
223   this.editor_ = new ImageEditor(
224       this.viewport_,
225       this.imageView_,
226       this.prompt_,
227       {
228         root: this.container_,
229         image: this.imageContainer_,
230         toolbar: this.editBarMain_,
231         mode: this.editBarModeWrapper_
232       },
233       SlideMode.editorModes,
234       this.displayStringFunction_,
235       this.onToolsVisibilityChanged_.bind(this));
236
237   this.editor_.getBuffer().addOverlay(
238       new SwipeOverlay(this.advanceManually.bind(this)));
239 };
240
241 /**
242  * Load items, display the selected item.
243  * @param {Rect} zoomFromRect Rectangle for zoom effect.
244  * @param {function} displayCallback Called when the image is displayed.
245  * @param {function} loadCallback Called when the image is displayed.
246  */
247 SlideMode.prototype.enter = function(
248     zoomFromRect, displayCallback, loadCallback) {
249   this.sequenceDirection_ = 0;
250   this.sequenceLength_ = 0;
251
252   var loadDone = function(loadType, delay) {
253     this.active_ = true;
254
255     this.selectionModel_.addEventListener('change', this.onSelectionBound_);
256     this.dataModel_.addEventListener('splice', this.onSpliceBound_);
257     this.dataModel_.addEventListener('content', this.onContentBound_);
258
259     ImageUtil.setAttribute(this.arrowBox_, 'active', this.getItemCount_() > 1);
260     this.ribbon_.enable();
261
262     // Wait 1000ms after the animation is done, then prefetch the next image.
263     this.requestPrefetch(1, delay + 1000);
264
265     if (loadCallback) loadCallback();
266   }.bind(this);
267
268   // The latest |leave| call might have left the image animating. Remove it.
269   this.unloadImage_();
270
271   if (this.getItemCount_() === 0) {
272     this.displayedIndex_ = -1;
273     //TODO(kaznacheev) Show this message in the grid mode too.
274     this.showErrorBanner_('GALLERY_NO_IMAGES');
275     loadDone();
276   } else {
277     // Remember the selection if it is empty or multiple. It will be restored
278     // in |leave| if the user did not changing the selection manually.
279     var currentSelection = this.selectionModel_.selectedIndexes;
280     if (currentSelection.length === 1)
281       this.savedSelection_ = null;
282     else
283       this.savedSelection_ = currentSelection;
284
285     // Ensure valid single selection.
286     // Note that the SlideMode object is not listening to selection change yet.
287     this.select(Math.max(0, this.getSelectedIndex()));
288     this.displayedIndex_ = this.getSelectedIndex();
289
290     var selectedItem = this.getSelectedItem();
291     // Show the selected item ASAP, then complete the initialization
292     // (loading the ribbon thumbnails can take some time).
293     this.metadataCache_.get(selectedItem.getEntry(), Gallery.METADATA_TYPE,
294         function(metadata) {
295           this.loadItem_(selectedItem.getEntry(), metadata,
296               zoomFromRect && this.imageView_.createZoomEffect(zoomFromRect),
297               displayCallback, loadDone);
298         }.bind(this));
299
300   }
301 };
302
303 /**
304  * Leave the mode.
305  * @param {Rect} zoomToRect Rectangle for zoom effect.
306  * @param {function} callback Called when the image is committed and
307  *   the zoom-out animation has started.
308  */
309 SlideMode.prototype.leave = function(zoomToRect, callback) {
310   var commitDone = function() {
311       this.stopEditing_();
312       this.stopSlideshow_();
313       ImageUtil.setAttribute(this.arrowBox_, 'active', false);
314       this.selectionModel_.removeEventListener(
315           'change', this.onSelectionBound_);
316       this.dataModel_.removeEventListener('splice', this.onSpliceBound_);
317       this.dataModel_.removeEventListener('content', this.onContentBound_);
318       this.ribbon_.disable();
319       this.active_ = false;
320       if (this.savedSelection_)
321         this.selectionModel_.selectedIndexes = this.savedSelection_;
322       this.unloadImage_(zoomToRect);
323       callback();
324     }.bind(this);
325
326   if (this.getItemCount_() === 0) {
327     this.showErrorBanner_(false);
328     commitDone();
329   } else {
330     this.commitItem_(commitDone);
331   }
332
333   // Disable the slide-mode only buttons when leaving.
334   this.editButton_.setAttribute('disabled', '');
335   this.printButton_.setAttribute('disabled', '');
336 };
337
338
339 /**
340  * Execute an action when the editor is not busy.
341  *
342  * @param {function} action Function to execute.
343  */
344 SlideMode.prototype.executeWhenReady = function(action) {
345   this.editor_.executeWhenReady(action);
346 };
347
348 /**
349  * @return {boolean} True if the mode has active tools (that should not fade).
350  */
351 SlideMode.prototype.hasActiveTool = function() {
352   return this.isEditing();
353 };
354
355 /**
356  * @return {number} Item count.
357  * @private
358  */
359 SlideMode.prototype.getItemCount_ = function() {
360   return this.dataModel_.length;
361 };
362
363 /**
364  * @param {number} index Index.
365  * @return {Gallery.Item} Item.
366  */
367 SlideMode.prototype.getItem = function(index) {
368   return this.dataModel_.item(index);
369 };
370
371 /**
372  * @return {Gallery.Item} Selected index.
373  */
374 SlideMode.prototype.getSelectedIndex = function() {
375   return this.selectionModel_.selectedIndex;
376 };
377
378 /**
379  * @return {Rect} Screen rectangle of the selected image.
380  */
381 SlideMode.prototype.getSelectedImageRect = function() {
382   if (this.getSelectedIndex() < 0)
383     return null;
384   else
385     return this.viewport_.getScreenClipped();
386 };
387
388 /**
389  * @return {Gallery.Item} Selected item.
390  */
391 SlideMode.prototype.getSelectedItem = function() {
392   return this.getItem(this.getSelectedIndex());
393 };
394
395 /**
396  * Toggles the full screen mode.
397  * @private
398  */
399 SlideMode.prototype.toggleFullScreen_ = function() {
400   util.toggleFullScreen(this.context_.appWindow,
401                         !util.isFullScreen(this.context_.appWindow));
402 };
403
404 /**
405  * Selection change handler.
406  *
407  * Commits the current image and displays the newly selected image.
408  * @private
409  */
410 SlideMode.prototype.onSelection_ = function() {
411   if (this.selectionModel_.selectedIndexes.length === 0)
412     return;  // Temporary empty selection.
413
414   // Forget the saved selection if the user changed the selection manually.
415   if (!this.isSlideshowOn_())
416     this.savedSelection_ = null;
417
418   if (this.getSelectedIndex() === this.displayedIndex_)
419     return;  // Do not reselect.
420
421   this.commitItem_(this.loadSelectedItem_.bind(this));
422 };
423
424 /**
425  * Handles changes in tools visibility, and if the header is dimmed, then
426  * requests disabling the draggable app region.
427  *
428  * @private
429  */
430 SlideMode.prototype.onToolsVisibilityChanged_ = function() {
431   var headerDimmed =
432       this.document_.querySelector('.header').hasAttribute('dimmed');
433   this.context_.onAppRegionChanged(!headerDimmed);
434 };
435
436 /**
437  * Change the selection.
438  *
439  * @param {number} index New selected index.
440  * @param {number=} opt_slideHint Slide animation direction (-1|1).
441  */
442 SlideMode.prototype.select = function(index, opt_slideHint) {
443   this.slideHint_ = opt_slideHint;
444   this.selectionModel_.selectedIndex = index;
445   this.selectionModel_.leadIndex = index;
446 };
447
448 /**
449  * Load the selected item.
450  *
451  * @private
452  */
453 SlideMode.prototype.loadSelectedItem_ = function() {
454   var slideHint = this.slideHint_;
455   this.slideHint_ = undefined;
456
457   var index = this.getSelectedIndex();
458   if (index === this.displayedIndex_)
459     return;  // Do not reselect.
460
461   var step = slideHint || (index - this.displayedIndex_);
462
463   if (Math.abs(step) != 1) {
464     // Long leap, the sequence is broken, we have no good prefetch candidate.
465     this.sequenceDirection_ = 0;
466     this.sequenceLength_ = 0;
467   } else if (this.sequenceDirection_ === step) {
468     // Keeping going in sequence.
469     this.sequenceLength_++;
470   } else {
471     // Reversed the direction. Reset the counter.
472     this.sequenceDirection_ = step;
473     this.sequenceLength_ = 1;
474   }
475
476   if (this.sequenceLength_ <= 1) {
477     // We have just broke the sequence. Touch the current image so that it stays
478     // in the cache longer.
479     this.imageView_.prefetch(this.imageView_.contentEntry_);
480   }
481
482   this.displayedIndex_ = index;
483
484   function shouldPrefetch(loadType, step, sequenceLength) {
485     // Never prefetch when selecting out of sequence.
486     if (Math.abs(step) != 1)
487       return false;
488
489     // Never prefetch after a video load (decoding the next image can freeze
490     // the UI for a second or two).
491     if (loadType === ImageView.LOAD_TYPE_VIDEO_FILE)
492       return false;
493
494     // Always prefetch if the previous load was from cache.
495     if (loadType === ImageView.LOAD_TYPE_CACHED_FULL)
496       return true;
497
498     // Prefetch if we have been going in the same direction for long enough.
499     return sequenceLength >= 3;
500   }
501
502   var selectedItem = this.getSelectedItem();
503   this.currentUniqueKey_++;
504   var selectedUniqueKey = this.currentUniqueKey_;
505   var onMetadata = function(metadata) {
506     // Discard, since another load has been invoked after this one.
507     if (selectedUniqueKey != this.currentUniqueKey_) return;
508     this.loadItem_(selectedItem.getEntry(), metadata,
509         new ImageView.Effect.Slide(step, this.isSlideshowPlaying_()),
510         function() {} /* no displayCallback */,
511         function(loadType, delay) {
512           // Discard, since another load has been invoked after this one.
513           if (selectedUniqueKey != this.currentUniqueKey_) return;
514           if (shouldPrefetch(loadType, step, this.sequenceLength_)) {
515             this.requestPrefetch(step, delay);
516           }
517           if (this.isSlideshowPlaying_())
518             this.scheduleNextSlide_();
519         }.bind(this));
520   }.bind(this);
521   this.metadataCache_.get(
522       selectedItem.getEntry(), Gallery.METADATA_TYPE, onMetadata);
523 };
524
525 /**
526  * Unload the current image.
527  *
528  * @param {Rect} zoomToRect Rectangle for zoom effect.
529  * @private
530  */
531 SlideMode.prototype.unloadImage_ = function(zoomToRect) {
532   this.imageView_.unload(zoomToRect);
533   this.container_.removeAttribute('video');
534 };
535
536 /**
537  * Data model 'splice' event handler.
538  * @param {Event} event Event.
539  * @private
540  */
541 SlideMode.prototype.onSplice_ = function(event) {
542   ImageUtil.setAttribute(this.arrowBox_, 'active', this.getItemCount_() > 1);
543
544   // Splice invalidates saved indices, drop the saved selection.
545   this.savedSelection_ = null;
546
547   if (event.removed.length != 1)
548     return;
549
550   // Delay the selection to let the ribbon splice handler work first.
551   setTimeout(function() {
552     if (event.index < this.dataModel_.length) {
553       // There is the next item, select it.
554       // The next item is now at the same index as the removed one, so we need
555       // to correct displayIndex_ so that loadSelectedItem_ does not think
556       // we are re-selecting the same item (and does right-to-left slide-in
557       // animation).
558       this.displayedIndex_ = event.index - 1;
559       this.select(event.index);
560     } else if (this.dataModel_.length) {
561       // Removed item is the rightmost, but there are more items.
562       this.select(event.index - 1);  // Select the new last index.
563     } else {
564       // No items left. Unload the image and show the banner.
565       this.commitItem_(function() {
566         this.unloadImage_();
567         this.showErrorBanner_('GALLERY_NO_IMAGES');
568       }.bind(this));
569     }
570   }.bind(this), 0);
571 };
572
573 /**
574  * @param {number} direction -1 for left, 1 for right.
575  * @return {number} Next index in the given direction, with wrapping.
576  * @private
577  */
578 SlideMode.prototype.getNextSelectedIndex_ = function(direction) {
579   function advance(index, limit) {
580     index += (direction > 0 ? 1 : -1);
581     if (index < 0)
582       return limit - 1;
583     if (index === limit)
584       return 0;
585     return index;
586   }
587
588   // If the saved selection is multiple the Slideshow should cycle through
589   // the saved selection.
590   if (this.isSlideshowOn_() &&
591       this.savedSelection_ && this.savedSelection_.length > 1) {
592     var pos = advance(this.savedSelection_.indexOf(this.getSelectedIndex()),
593         this.savedSelection_.length);
594     return this.savedSelection_[pos];
595   } else {
596     return advance(this.getSelectedIndex(), this.getItemCount_());
597   }
598 };
599
600 /**
601  * Advance the selection based on the pressed key ID.
602  * @param {string} keyID Key identifier.
603  */
604 SlideMode.prototype.advanceWithKeyboard = function(keyID) {
605   this.advanceManually(keyID === 'Up' || keyID === 'Left' ? -1 : 1);
606 };
607
608 /**
609  * Advance the selection as a result of a user action (as opposed to an
610  * automatic change in the slideshow mode).
611  * @param {number} direction -1 for left, 1 for right.
612  */
613 SlideMode.prototype.advanceManually = function(direction) {
614   if (this.isSlideshowPlaying_()) {
615     this.pauseSlideshow_();
616     cr.dispatchSimpleEvent(this, 'useraction');
617   }
618   this.selectNext(direction);
619 };
620
621 /**
622  * Select the next item.
623  * @param {number} direction -1 for left, 1 for right.
624  */
625 SlideMode.prototype.selectNext = function(direction) {
626   this.select(this.getNextSelectedIndex_(direction), direction);
627 };
628
629 /**
630  * Select the first item.
631  */
632 SlideMode.prototype.selectFirst = function() {
633   this.select(0);
634 };
635
636 /**
637  * Select the last item.
638  */
639 SlideMode.prototype.selectLast = function() {
640   this.select(this.getItemCount_() - 1);
641 };
642
643 // Loading/unloading
644
645 /**
646  * Load and display an item.
647  *
648  * @param {FileEntry} entry Item entry to be loaded.
649  * @param {Object} metadata Item metadata.
650  * @param {Object} effect Transition effect object.
651  * @param {function} displayCallback Called when the image is displayed
652  *     (which can happen before the image load due to caching).
653  * @param {function} loadCallback Called when the image is fully loaded.
654  * @private
655  */
656 SlideMode.prototype.loadItem_ = function(
657     entry, metadata, effect, displayCallback, loadCallback) {
658   this.selectedImageMetadata_ = MetadataCache.cloneMetadata(metadata);
659
660   this.showSpinner_(true);
661
662   var loadDone = function(loadType, delay, error) {
663     var video = this.isShowingVideo_();
664     ImageUtil.setAttribute(this.container_, 'video', video);
665
666     this.showSpinner_(false);
667     if (loadType === ImageView.LOAD_TYPE_ERROR) {
668       // if we have a specific error, then display it
669       if (error) {
670         this.showErrorBanner_(error);
671       } else {
672         // otherwise try to infer general error
673         this.showErrorBanner_(
674             video ? 'GALLERY_VIDEO_ERROR' : 'GALLERY_IMAGE_ERROR');
675       }
676     } else if (loadType === ImageView.LOAD_TYPE_OFFLINE) {
677       this.showErrorBanner_(
678           video ? 'GALLERY_VIDEO_OFFLINE' : 'GALLERY_IMAGE_OFFLINE');
679     }
680
681     if (video) {
682       // The editor toolbar does not make sense for video, hide it.
683       this.stopEditing_();
684       this.mediaControls_.attachMedia(this.imageView_.getVideo());
685
686       // TODO(kaznacheev): Add metrics for video playback.
687     } else {
688       ImageUtil.metrics.recordUserAction(ImageUtil.getMetricName('View'));
689
690       var toMillions = function(number) {
691         return Math.round(number / (1000 * 1000));
692       };
693
694       ImageUtil.metrics.recordSmallCount(ImageUtil.getMetricName('Size.MB'),
695           toMillions(metadata.filesystem.size));
696
697       var canvas = this.imageView_.getCanvas();
698       ImageUtil.metrics.recordSmallCount(ImageUtil.getMetricName('Size.MPix'),
699           toMillions(canvas.width * canvas.height));
700
701       var extIndex = entry.name.lastIndexOf('.');
702       var ext = extIndex < 0 ? '' :
703           entry.name.substr(extIndex + 1).toLowerCase();
704       if (ext === 'jpeg') ext = 'jpg';
705       ImageUtil.metrics.recordEnum(
706           ImageUtil.getMetricName('FileType'), ext, ImageUtil.FILE_TYPES);
707     }
708
709     // Enable or disable buttons for editing and printing.
710     if (video || error) {
711       this.editButton_.setAttribute('disabled', '');
712       this.printButton_.setAttribute('disabled', '');
713     } else {
714       this.editButton_.removeAttribute('disabled');
715       this.printButton_.removeAttribute('disabled');
716     }
717
718     // For once edited image, disallow the 'overwrite' setting change.
719     ImageUtil.setAttribute(this.options_, 'saved',
720         !this.getSelectedItem().isOriginal());
721
722     util.platform.getPreference(SlideMode.OVERWRITE_BUBBLE_KEY,
723         function(value) {
724           var times = typeof value === 'string' ? parseInt(value, 10) : 0;
725           if (times < SlideMode.OVERWRITE_BUBBLE_MAX_TIMES) {
726             this.bubble_.hidden = false;
727             if (this.isEditing()) {
728               util.platform.setPreference(
729                   SlideMode.OVERWRITE_BUBBLE_KEY, times + 1);
730             }
731           }
732         }.bind(this));
733
734     loadCallback(loadType, delay);
735   }.bind(this);
736
737   var displayDone = function() {
738     cr.dispatchSimpleEvent(this, 'image-displayed');
739     displayCallback();
740   }.bind(this);
741
742   this.editor_.openSession(entry, metadata, effect,
743       this.saveCurrentImage_.bind(this), displayDone, loadDone);
744 };
745
746 /**
747  * Commit changes to the current item and reset all messages/indicators.
748  *
749  * @param {function} callback Callback.
750  * @private
751  */
752 SlideMode.prototype.commitItem_ = function(callback) {
753   this.showSpinner_(false);
754   this.showErrorBanner_(false);
755   this.editor_.getPrompt().hide();
756
757   // Detach any media attached to the controls.
758   if (this.mediaControls_.getMedia())
759     this.mediaControls_.detachMedia();
760
761   // If showing the video, then pause it. Note, that it may not be attached
762   // to the media controls yet.
763   if (this.isShowingVideo_()) {
764     this.imageView_.getVideo().pause();
765     // Force stop downloading, if uncached on Drive.
766     this.imageView_.getVideo().src = '';
767     this.imageView_.getVideo().load();
768   }
769
770   this.editor_.closeSession(callback);
771 };
772
773 /**
774  * Request a prefetch for the next image.
775  *
776  * @param {number} direction -1 or 1.
777  * @param {number} delay Delay in ms. Used to prevent the CPU-heavy image
778  *   loading from disrupting the animation that might be still in progress.
779  */
780 SlideMode.prototype.requestPrefetch = function(direction, delay) {
781   if (this.getItemCount_() <= 1) return;
782
783   var index = this.getNextSelectedIndex_(direction);
784   var nextItemEntry = this.getItem(index).getEntry();
785   this.imageView_.prefetch(nextItemEntry, delay);
786 };
787
788 // Event handlers.
789
790 /**
791  * Unload handler, to be called from the top frame.
792  * @param {boolean} exiting True if the app is exiting.
793  */
794 SlideMode.prototype.onUnload = function(exiting) {
795   if (this.isShowingVideo_() && this.mediaControls_.isPlaying()) {
796     this.mediaControls_.savePosition(exiting);
797   }
798 };
799
800 /**
801  * beforeunload handler, to be called from the top frame.
802  * @return {string} Message to show if there are unsaved changes.
803  */
804 SlideMode.prototype.onBeforeUnload = function() {
805   if (this.editor_.isBusy())
806     return this.displayStringFunction_('GALLERY_UNSAVED_CHANGES');
807   return null;
808 };
809
810 /**
811  * Click handler for the image container.
812  *
813  * @param {Event} event Mouse click event.
814  * @private
815  */
816 SlideMode.prototype.onClick_ = function(event) {
817   if (!this.isShowingVideo_() || !this.mediaControls_.getMedia())
818     return;
819   if (event.ctrlKey) {
820     this.mediaControls_.toggleLoopedModeWithFeedback(true);
821     if (!this.mediaControls_.isPlaying())
822       this.mediaControls_.togglePlayStateWithFeedback();
823   } else {
824     this.mediaControls_.togglePlayStateWithFeedback();
825   }
826 };
827
828 /**
829  * Click handler for the entire document.
830  * @param {Event} e Mouse click event.
831  * @private
832  */
833 SlideMode.prototype.onDocumentClick_ = function(e) {
834   // Close the bubble if clicked outside of it and if it is visible.
835   if (!this.bubble_.contains(e.target) &&
836       !this.editButton_.contains(e.target) &&
837       !this.arrowLeft_.contains(e.target) &&
838       !this.arrowRight_.contains(e.target) &&
839       !this.bubble_.hidden) {
840     this.bubble_.hidden = true;
841   }
842 };
843
844 /**
845  * Keydown handler.
846  *
847  * @param {Event} event Event.
848  * @return {boolean} True if handled.
849  */
850 SlideMode.prototype.onKeyDown = function(event) {
851   var keyID = util.getKeyModifiers(event) + event.keyIdentifier;
852
853   if (this.isSlideshowOn_()) {
854     switch (keyID) {
855       case 'U+001B':  // Escape exits the slideshow.
856         this.stopSlideshow_(event);
857         break;
858
859       case 'U+0020':  // Space pauses/resumes the slideshow.
860         this.toggleSlideshowPause_();
861         break;
862
863       case 'Up':
864       case 'Down':
865       case 'Left':
866       case 'Right':
867         this.advanceWithKeyboard(keyID);
868         break;
869     }
870     return true;  // Consume all keystrokes in the slideshow mode.
871   }
872
873   if (this.isEditing() && this.editor_.onKeyDown(event))
874     return true;
875
876   switch (keyID) {
877     case 'U+0020':  // Space toggles the video playback.
878       if (this.isShowingVideo_() && this.mediaControls_.getMedia())
879         this.mediaControls_.togglePlayStateWithFeedback();
880       break;
881
882     case 'Ctrl-U+0050':  // Ctrl+'p' prints the current image.
883       if (!this.printButton_.hasAttribute('disabled'))
884         this.print_();
885       break;
886
887     case 'U+0045':  // 'e' toggles the editor.
888       if (!this.editButton_.hasAttribute('disabled'))
889         this.toggleEditor(event);
890       break;
891
892     case 'U+001B':  // Escape
893       if (!this.isEditing())
894         return false;  // Not handled.
895       this.toggleEditor(event);
896       break;
897
898     case 'Home':
899       this.selectFirst();
900       break;
901     case 'End':
902       this.selectLast();
903       break;
904     case 'Up':
905     case 'Down':
906     case 'Left':
907     case 'Right':
908       this.advanceWithKeyboard(keyID);
909       break;
910
911     default: return false;
912   }
913
914   return true;
915 };
916
917 /**
918  * Resize handler.
919  * @private
920  */
921 SlideMode.prototype.onResize_ = function() {
922   this.viewport_.sizeByFrameAndFit(this.container_);
923   this.viewport_.repaint();
924 };
925
926 /**
927  * Update thumbnails.
928  */
929 SlideMode.prototype.updateThumbnails = function() {
930   this.ribbon_.reset();
931   if (this.active_)
932     this.ribbon_.redraw();
933 };
934
935 // Saving
936
937 /**
938  * Save the current image to a file.
939  *
940  * @param {function} callback Callback.
941  * @private
942  */
943 SlideMode.prototype.saveCurrentImage_ = function(callback) {
944   var item = this.getSelectedItem();
945   var oldEntry = item.getEntry();
946   var canvas = this.imageView_.getCanvas();
947
948   this.showSpinner_(true);
949   var metadataEncoder = ImageEncoder.encodeMetadata(
950       this.selectedImageMetadata_.media, canvas, 1 /* quality */);
951
952   this.selectedImageMetadata_ = ContentProvider.ConvertContentMetadata(
953       metadataEncoder.getMetadata(), this.selectedImageMetadata_);
954
955   item.saveToFile(
956       this.context_.saveDirEntry,
957       this.shouldOverwriteOriginal_(),
958       canvas,
959       metadataEncoder,
960       function(success) {
961         // TODO(kaznacheev): Implement write error handling.
962         // Until then pretend that the save succeeded.
963         this.showSpinner_(false);
964         this.flashSavedLabel_();
965
966         var event = new Event('content');
967         event.item = item;
968         event.oldEntry = oldEntry;
969         event.metadata = this.selectedImageMetadata_;
970         this.dataModel_.dispatchEvent(event);
971
972         // Allow changing the 'Overwrite original' setting only if the user
973         // used Undo to restore the original image AND it is not a copy.
974         // Otherwise lock the setting in its current state.
975         var mayChangeOverwrite = !this.editor_.canUndo() && item.isOriginal();
976         ImageUtil.setAttribute(this.options_, 'saved', !mayChangeOverwrite);
977
978         if (this.imageView_.getContentRevision() === 1) {  // First edit.
979           ImageUtil.metrics.recordUserAction(ImageUtil.getMetricName('Edit'));
980         }
981
982         if (!util.isSameEntry(oldEntry, item.getEntry())) {
983           this.dataModel_.splice(
984               this.getSelectedIndex(), 0, new Gallery.Item(oldEntry));
985           // The ribbon will ignore the splice above and redraw after the
986           // select call below (while being obscured by the Editor toolbar,
987           // so there is no need for nice animation here).
988           // SlideMode will ignore the selection change as the displayed item
989           // index has not changed.
990           this.select(++this.displayedIndex_);
991         }
992         callback();
993         cr.dispatchSimpleEvent(this, 'image-saved');
994       }.bind(this));
995 };
996
997 /**
998  * Update caches when the selected item has been renamed.
999  * @param {Event} event Event.
1000  * @private
1001  */
1002 SlideMode.prototype.onContentChange_ = function(event) {
1003   var newEntry = event.item.getEntry();
1004   if (util.isSameEntry(newEntry, event.oldEntry))
1005     this.imageView_.changeEntry(newEntry);
1006   this.metadataCache_.clear(event.oldEntry, Gallery.METADATA_TYPE);
1007 };
1008
1009 /**
1010  * Flash 'Saved' label briefly to indicate that the image has been saved.
1011  * @private
1012  */
1013 SlideMode.prototype.flashSavedLabel_ = function() {
1014   var setLabelHighlighted =
1015       ImageUtil.setAttribute.bind(null, this.savedLabel_, 'highlighted');
1016   setTimeout(setLabelHighlighted.bind(null, true), 0);
1017   setTimeout(setLabelHighlighted.bind(null, false), 300);
1018 };
1019
1020 /**
1021  * Local storage key for the 'Overwrite original' setting.
1022  * @type {string}
1023  */
1024 SlideMode.OVERWRITE_KEY = 'gallery-overwrite-original';
1025
1026 /**
1027  * Local storage key for the number of times that
1028  * the overwrite info bubble has been displayed.
1029  * @type {string}
1030  */
1031 SlideMode.OVERWRITE_BUBBLE_KEY = 'gallery-overwrite-bubble';
1032
1033 /**
1034  * Max number that the overwrite info bubble is shown.
1035  * @type {number}
1036  */
1037 SlideMode.OVERWRITE_BUBBLE_MAX_TIMES = 5;
1038
1039 /**
1040  * @return {boolean} True if 'Overwrite original' is set.
1041  * @private
1042  */
1043 SlideMode.prototype.shouldOverwriteOriginal_ = function() {
1044    return this.overwriteOriginal_.checked;
1045 };
1046
1047 /**
1048  * 'Overwrite original' checkbox handler.
1049  * @param {Event} event Event.
1050  * @private
1051  */
1052 SlideMode.prototype.onOverwriteOriginalClick_ = function(event) {
1053   util.platform.setPreference(SlideMode.OVERWRITE_KEY, event.target.checked);
1054 };
1055
1056 /**
1057  * Overwrite info bubble close handler.
1058  * @private
1059  */
1060 SlideMode.prototype.onCloseBubble_ = function() {
1061   this.bubble_.hidden = true;
1062   util.platform.setPreference(SlideMode.OVERWRITE_BUBBLE_KEY,
1063       SlideMode.OVERWRITE_BUBBLE_MAX_TIMES);
1064 };
1065
1066 // Slideshow
1067
1068 /**
1069  * Slideshow interval in ms.
1070  */
1071 SlideMode.SLIDESHOW_INTERVAL = 5000;
1072
1073 /**
1074  * First slideshow interval in ms. It should be shorter so that the user
1075  * is not guessing whether the button worked.
1076  */
1077 SlideMode.SLIDESHOW_INTERVAL_FIRST = 1000;
1078
1079 /**
1080  * Empirically determined duration of the fullscreen toggle animation.
1081  */
1082 SlideMode.FULLSCREEN_TOGGLE_DELAY = 500;
1083
1084 /**
1085  * @return {boolean} True if the slideshow is on.
1086  * @private
1087  */
1088 SlideMode.prototype.isSlideshowOn_ = function() {
1089   return this.container_.hasAttribute('slideshow');
1090 };
1091
1092 /**
1093  * Start the slideshow.
1094  * @param {number=} opt_interval First interval in ms.
1095  * @param {Event=} opt_event Event.
1096  */
1097 SlideMode.prototype.startSlideshow = function(opt_interval, opt_event) {
1098   // Set the attribute early to prevent the toolbar from flashing when
1099   // the slideshow is being started from the mosaic view.
1100   this.container_.setAttribute('slideshow', 'playing');
1101
1102   if (this.active_) {
1103     this.stopEditing_();
1104   } else {
1105     // We are in the Mosaic mode. Toggle the mode but remember to return.
1106     this.leaveAfterSlideshow_ = true;
1107     this.toggleMode_(this.startSlideshow.bind(
1108         this, SlideMode.SLIDESHOW_INTERVAL, opt_event));
1109     return;
1110   }
1111
1112   if (opt_event)  // Caused by user action, notify the Gallery.
1113     cr.dispatchSimpleEvent(this, 'useraction');
1114
1115   this.fullscreenBeforeSlideshow_ = util.isFullScreen(this.context_.appWindow);
1116   if (!this.fullscreenBeforeSlideshow_) {
1117     // Wait until the zoom animation from the mosaic mode is done.
1118     setTimeout(this.toggleFullScreen_.bind(this),
1119                ImageView.ZOOM_ANIMATION_DURATION);
1120     opt_interval = (opt_interval || SlideMode.SLIDESHOW_INTERVAL) +
1121         SlideMode.FULLSCREEN_TOGGLE_DELAY;
1122   }
1123
1124   this.resumeSlideshow_(opt_interval);
1125 };
1126
1127 /**
1128  * Stop the slideshow.
1129  * @param {Event=} opt_event Event.
1130  * @private
1131  */
1132 SlideMode.prototype.stopSlideshow_ = function(opt_event) {
1133   if (!this.isSlideshowOn_())
1134     return;
1135
1136   if (opt_event)  // Caused by user action, notify the Gallery.
1137     cr.dispatchSimpleEvent(this, 'useraction');
1138
1139   this.pauseSlideshow_();
1140   this.container_.removeAttribute('slideshow');
1141
1142   // Do not restore fullscreen if we exited fullscreen while in slideshow.
1143   var fullscreen = util.isFullScreen(this.context_.appWindow);
1144   var toggleModeDelay = 0;
1145   if (!this.fullscreenBeforeSlideshow_ && fullscreen) {
1146     this.toggleFullScreen_();
1147     toggleModeDelay = SlideMode.FULLSCREEN_TOGGLE_DELAY;
1148   }
1149   if (this.leaveAfterSlideshow_) {
1150     this.leaveAfterSlideshow_ = false;
1151     setTimeout(this.toggleMode_.bind(this), toggleModeDelay);
1152   }
1153 };
1154
1155 /**
1156  * @return {boolean} True if the slideshow is playing (not paused).
1157  * @private
1158  */
1159 SlideMode.prototype.isSlideshowPlaying_ = function() {
1160   return this.container_.getAttribute('slideshow') === 'playing';
1161 };
1162
1163 /**
1164  * Pause/resume the slideshow.
1165  * @private
1166  */
1167 SlideMode.prototype.toggleSlideshowPause_ = function() {
1168   cr.dispatchSimpleEvent(this, 'useraction');  // Show the tools.
1169   if (this.isSlideshowPlaying_()) {
1170     this.pauseSlideshow_();
1171   } else {
1172     this.resumeSlideshow_(SlideMode.SLIDESHOW_INTERVAL_FIRST);
1173   }
1174 };
1175
1176 /**
1177  * @param {number=} opt_interval Slideshow interval in ms.
1178  * @private
1179  */
1180 SlideMode.prototype.scheduleNextSlide_ = function(opt_interval) {
1181   console.assert(this.isSlideshowPlaying_(), 'Inconsistent slideshow state');
1182
1183   if (this.slideShowTimeout_)
1184     clearTimeout(this.slideShowTimeout_);
1185
1186   this.slideShowTimeout_ = setTimeout(function() {
1187         this.slideShowTimeout_ = null;
1188         this.selectNext(1);
1189       }.bind(this),
1190       opt_interval || SlideMode.SLIDESHOW_INTERVAL);
1191 };
1192
1193 /**
1194  * Resume the slideshow.
1195  * @param {number=} opt_interval Slideshow interval in ms.
1196  * @private
1197  */
1198 SlideMode.prototype.resumeSlideshow_ = function(opt_interval) {
1199   this.container_.setAttribute('slideshow', 'playing');
1200   this.scheduleNextSlide_(opt_interval);
1201 };
1202
1203 /**
1204  * Pause the slideshow.
1205  * @private
1206  */
1207 SlideMode.prototype.pauseSlideshow_ = function() {
1208   this.container_.setAttribute('slideshow', 'paused');
1209   if (this.slideShowTimeout_) {
1210     clearTimeout(this.slideShowTimeout_);
1211     this.slideShowTimeout_ = null;
1212   }
1213 };
1214
1215 /**
1216  * @return {boolean} True if the editor is active.
1217  */
1218 SlideMode.prototype.isEditing = function() {
1219   return this.container_.hasAttribute('editing');
1220 };
1221
1222 /**
1223  * Stop editing.
1224  * @private
1225  */
1226 SlideMode.prototype.stopEditing_ = function() {
1227   if (this.isEditing())
1228     this.toggleEditor();
1229 };
1230
1231 /**
1232  * Activate/deactivate editor.
1233  * @param {Event=} opt_event Event.
1234  */
1235 SlideMode.prototype.toggleEditor = function(opt_event) {
1236   if (opt_event)  // Caused by user action, notify the Gallery.
1237     cr.dispatchSimpleEvent(this, 'useraction');
1238
1239   if (!this.active_) {
1240     this.toggleMode_(this.toggleEditor.bind(this));
1241     return;
1242   }
1243
1244   this.stopSlideshow_();
1245   if (!this.isEditing() && this.isShowingVideo_())
1246     return;  // No editing for videos.
1247
1248   ImageUtil.setAttribute(this.container_, 'editing', !this.isEditing());
1249
1250   if (this.isEditing()) { // isEditing has just been flipped to a new value.
1251     if (this.context_.readonlyDirName) {
1252       this.editor_.getPrompt().showAt(
1253           'top', 'GALLERY_READONLY_WARNING', 0, this.context_.readonlyDirName);
1254     }
1255   } else {
1256     this.editor_.getPrompt().hide();
1257     this.editor_.leaveModeGently();
1258   }
1259 };
1260
1261 /**
1262  * Prints the current item.
1263  * @private
1264  */
1265 SlideMode.prototype.print_ = function() {
1266   cr.dispatchSimpleEvent(this, 'useraction');
1267   window.print();
1268 };
1269
1270 /**
1271  * Display the error banner.
1272  * @param {string} message Message.
1273  * @private
1274  */
1275 SlideMode.prototype.showErrorBanner_ = function(message) {
1276   if (message) {
1277     this.errorBanner_.textContent = this.displayStringFunction_(message);
1278   }
1279   ImageUtil.setAttribute(this.container_, 'error', !!message);
1280 };
1281
1282 /**
1283  * Show/hide the busy spinner.
1284  *
1285  * @param {boolean} on True if show, false if hide.
1286  * @private
1287  */
1288 SlideMode.prototype.showSpinner_ = function(on) {
1289   if (this.spinnerTimer_) {
1290     clearTimeout(this.spinnerTimer_);
1291     this.spinnerTimer_ = null;
1292   }
1293
1294   if (on) {
1295     this.spinnerTimer_ = setTimeout(function() {
1296       this.spinnerTimer_ = null;
1297       ImageUtil.setAttribute(this.container_, 'spinner', true);
1298     }.bind(this), 1000);
1299   } else {
1300     ImageUtil.setAttribute(this.container_, 'spinner', false);
1301   }
1302 };
1303
1304 /**
1305  * @return {boolean} True if the current item is a video.
1306  * @private
1307  */
1308 SlideMode.prototype.isShowingVideo_ = function() {
1309   return !!this.imageView_.getVideo();
1310 };
1311
1312 /**
1313  * Overlay that handles swipe gestures. Changes to the next or previous file.
1314  * @param {function(number)} callback A callback accepting the swipe direction
1315  *    (1 means left, -1 right).
1316  * @constructor
1317  * @implements {ImageBuffer.Overlay}
1318  */
1319 function SwipeOverlay(callback) {
1320   this.callback_ = callback;
1321 }
1322
1323 /**
1324  * Inherit ImageBuffer.Overlay.
1325  */
1326 SwipeOverlay.prototype.__proto__ = ImageBuffer.Overlay.prototype;
1327
1328 /**
1329  * @param {number} x X pointer position.
1330  * @param {number} y Y pointer position.
1331  * @param {boolean} touch True if dragging caused by touch.
1332  * @return {function} The closure to call on drag.
1333  */
1334 SwipeOverlay.prototype.getDragHandler = function(x, y, touch) {
1335   if (!touch)
1336     return null;
1337   var origin = x;
1338   var done = false;
1339   return function(x, y) {
1340     if (!done && origin - x > SwipeOverlay.SWIPE_THRESHOLD) {
1341       this.callback_(1);
1342       done = true;
1343     } else if (!done && x - origin > SwipeOverlay.SWIPE_THRESHOLD) {
1344       this.callback_(-1);
1345       done = true;
1346     }
1347   }.bind(this);
1348 };
1349
1350 /**
1351  * If the user touched the image and moved the finger more than SWIPE_THRESHOLD
1352  * horizontally it's considered as a swipe gesture (change the current image).
1353  */
1354 SwipeOverlay.SWIPE_THRESHOLD = 100;