Supervised user whitelists: Cleanup
[chromium-blink-merge.git] / ui / file_manager / gallery / js / slide_mode.js
blobd492ed944099b2c4a66a0b2bf14b584739bb715f
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.
5 /**
6  * Slide mode displays a single image and has a set of controls to navigate
7  * between the images and to edit an image.
8  *
9  * @param {!HTMLElement} container Main container element.
10  * @param {!HTMLElement} content Content container element.
11  * @param {!HTMLElement} toolbar Toolbar element.
12  * @param {!ImageEditor.Prompt} prompt Prompt.
13  * @param {!ErrorBanner} errorBanner Error banner.
14  * @param {!cr.ui.ArrayDataModel} dataModel Data model.
15  * @param {!cr.ui.ListSelectionModel} selectionModel Selection model.
16  * @param {!MetadataModel} metadataModel
17  * @param {!ThumbnailModel} thumbnailModel
18  * @param {!Object} context Context.
19  * @param {!VolumeManagerWrapper} volumeManager Volume manager.
20  * @param {function(function())} toggleMode Function to toggle the Gallery mode.
21  * @param {function(string):string} displayStringFunction String formatting
22  *     function.
24  * @constructor
25  * @struct
26  * @suppress {checkStructDictInheritance}
27  * @extends {cr.EventTarget}
28  */
29 function SlideMode(container, content, toolbar, prompt, errorBanner, dataModel,
30     selectionModel, metadataModel, thumbnailModel, context, volumeManager,
31     toggleMode, displayStringFunction) {
32   /**
33    * @type {!HTMLElement}
34    * @private
35    * @const
36    */
37   this.container_ = container;
39   /**
40    * @type {!Document}
41    * @private
42    * @const
43    */
44   this.document_ = assert(container.ownerDocument);
46   /**
47    * @type {!HTMLElement}
48    * @const
49    */
50   this.content = content;
52   /**
53    * @type {!HTMLElement}
54    * @private
55    * @const
56    */
57   this.toolbar_ = toolbar;
59   /**
60    * @type {!ImageEditor.Prompt}
61    * @private
62    * @const
63    */
64   this.prompt_ = prompt;
66   /**
67    * @type {!ErrorBanner}
68    * @private
69    * @const
70    */
71   this.errorBanner_ = errorBanner;
73   /**
74    * @type {!cr.ui.ArrayDataModel}
75    * @private
76    * @const
77    */
78   this.dataModel_ = dataModel;
80   /**
81    * @type {!cr.ui.ListSelectionModel}
82    * @private
83    * @const
84    */
85   this.selectionModel_ = selectionModel;
87   /**
88    * @type {!Object}
89    * @private
90    * @const
91    */
92   this.context_ = context;
94   /**
95    * @type {!VolumeManagerWrapper}
96    * @private
97    * @const
98    */
99   this.volumeManager_ = volumeManager;
101   /**
102    * @type {function(function())}
103    * @private
104    * @const
105    */
106   this.toggleMode_ = toggleMode;
108   /**
109    * @type {function(string):string}
110    * @private
111    * @const
112    */
113   this.displayStringFunction_ = displayStringFunction;
115   /**
116    * @type {function(this:SlideMode)}
117    * @private
118    * @const
119    */
120   this.onSelectionBound_ = this.onSelection_.bind(this);
122   /**
123    * @type {function(this:SlideMode,!Event)}
124    * @private
125    * @const
126    */
127   this.onSpliceBound_ = this.onSplice_.bind(this);
129   /**
130    * Unique numeric key, incremented per each load attempt used to discard
131    * old attempts. This can happen especially when changing selection fast or
132    * Internet connection is slow.
133    *
134    * @type {number}
135    * @private
136    */
137   this.currentUniqueKey_ = 0;
139   /**
140    * @type {number}
141    * @private
142    */
143   this.sequenceDirection_ = 0;
145   /**
146    * @type {number}
147    * @private
148    */
149   this.sequenceLength_ = 0;
151   /**
152    * @type {Array.<number>}
153    * @private
154    */
155   this.savedSelection_ = null;
157   /**
158    * @type {Gallery.Item}
159    * @private
160    */
161   this.displayedItem_ = null;
163   /**
164    * @type {?number}
165    * @private
166    */
167   this.slideHint_ = null;
169   /**
170    * @type {boolean}
171    * @private
172    */
173   this.active_ = false;
175   /**
176    * @type {boolean}
177    * @private
178    */
179   this.leaveAfterSlideshow_ = false;
181   /**
182    * @type {boolean}
183    * @private
184    */
185   this.fullscreenBeforeSlideshow_ = false;
187   /**
188    * @type {?number}
189    * @private
190    */
191   this.slideShowTimeout_ = null;
193   /**
194    * @type {?number}
195    * @private
196    */
197   this.spinnerTimer_ = null;
199   window.addEventListener('resize', this.onResize_.bind(this));
201   // ----------------------------------------------------------------
202   // Initializes the UI.
204   /**
205    * Container for displayed image.
206    * @type {!HTMLElement}
207    * @private
208    * @const
209    */
210   this.imageContainer_ = util.createChild(queryRequiredElement(
211       this.document_, '.content'), 'image-container');
212   this.imageContainer_.addEventListener('click', this.onClick_.bind(this));
214   this.document_.addEventListener('click', this.onDocumentClick_.bind(this));
216   /**
217    * Overwrite options and info bubble.
218    * @type {!HTMLElement}
219    * @private
220    * @const
221    */
222   this.options_ = util.createChild(queryRequiredElement(
223       this.toolbar_, '.filename-spacer'), 'options');
225   /**
226    * @type {!HTMLElement}
227    * @private
228    * @const
229    */
230   this.savedLabel_ = util.createChild(this.options_, 'saved');
231   this.savedLabel_.textContent = this.displayStringFunction_('GALLERY_SAVED');
233   /**
234    * @type {!HTMLElement}
235    * @private
236    * @const
237    */
238   this.overwriteOriginalBox_ = util.createChild(
239       this.options_, 'overwrite-original');
241   /**
242    * @type {!HTMLElement}
243    * @private
244    * @const
245    */
246   this.overwriteOriginal_ = util.createChild(
247       this.overwriteOriginalBox_, '', 'input');
248   this.overwriteOriginal_.type = 'checkbox';
249   this.overwriteOriginal_.id = 'overwrite-checkbox';
250   chrome.storage.local.get(SlideMode.OVERWRITE_KEY, function(values) {
251     var value = values[SlideMode.OVERWRITE_KEY];
252     // Out-of-the box default is 'true'
253     this.overwriteOriginal_.checked =
254         (value === 'false' || value === false) ? false : true;
255   }.bind(this));
256   this.overwriteOriginal_.addEventListener('click',
257       this.onOverwriteOriginalClick_.bind(this));
259   /**
260    * @type {!HTMLElement}
261    * @const
262    */
263   var overwriteLabel = util.createChild(
264       this.overwriteOriginalBox_, '', 'label');
265   overwriteLabel.textContent =
266       this.displayStringFunction_('GALLERY_OVERWRITE_ORIGINAL');
267   overwriteLabel.setAttribute('for', 'overwrite-checkbox');
269   /**
270    * @type {!HTMLElement}
271    * @private
272    * @const
273    */
274   this.bubble_ = util.createChild(this.toolbar_, 'bubble');
275   this.bubble_.hidden = true;
277   /**
278    * @type {!HTMLElement}
279    * @const
280    */
281   var bubbleContent = util.createChild(this.bubble_);
282   bubbleContent.innerHTML = this.displayStringFunction_(
283       'GALLERY_OVERWRITE_BUBBLE');
285   util.createChild(this.bubble_, 'pointer bottom', 'span');
287   /**
288    * @type {!HTMLElement}
289    * @const
290    */
291   var bubbleClose = util.createChild(this.bubble_, 'close-x');
292   bubbleClose.addEventListener('click', this.onCloseBubble_.bind(this));
294   /**
295    * Ribbon and related controls.
296    * @type {!HTMLElement}
297    * @private
298    * @const
299    */
300   this.arrowBox_ = util.createChild(this.container_, 'arrow-box');
302   /**
303    * @type {!HTMLElement}
304    * @private
305    * @const
306    */
307   this.arrowLeft_ = util.createChild(
308       this.arrowBox_, 'arrow left tool dimmable');
309   this.arrowLeft_.addEventListener('click',
310       this.advanceManually.bind(this, -1));
311   util.createChild(this.arrowLeft_);
313   util.createChild(this.arrowBox_, 'arrow-spacer');
315   /**
316    * @type {!HTMLElement}
317    * @private
318    * @const
319    */
320   this.arrowRight_ = util.createChild(
321       this.arrowBox_, 'arrow right tool dimmable');
322   this.arrowRight_.addEventListener('click',
323       this.advanceManually.bind(this, 1));
324   util.createChild(this.arrowRight_);
326   /**
327    * @type {!HTMLElement}
328    * @private
329    * @const
330    */
331   this.ribbonSpacer_ = queryRequiredElement(this.toolbar_, '.ribbon-spacer');
333   /**
334    * @type {!Ribbon}
335    * @private
336    * @const
337    */
338   this.ribbon_ = new Ribbon(
339       this.document_, this.dataModel_, this.selectionModel_, thumbnailModel);
340   this.ribbonSpacer_.appendChild(this.ribbon_);
342   util.createChild(this.container_, 'spinner');
344   /**
345    * @type {!HTMLElement}
346    * @const
347    */
348   var slideShowButton = queryRequiredElement(this.toolbar_, 'button.slideshow');
349   slideShowButton.title = this.displayStringFunction_('GALLERY_SLIDESHOW');
350   slideShowButton.addEventListener('click',
351       this.startSlideshow.bind(this, SlideMode.SLIDESHOW_INTERVAL_FIRST));
353   /**
354    * @type {!HTMLElement}
355    * @const
356    */
357   var slideShowToolbar = util.createChild(
358       this.container_, 'tool slideshow-toolbar');
359   util.createChild(slideShowToolbar, 'slideshow-play').
360       addEventListener('click', this.toggleSlideshowPause_.bind(this));
361   util.createChild(slideShowToolbar, 'slideshow-end').
362       addEventListener('click', this.stopSlideshow_.bind(this));
364   // Editor.
365   /**
366    * @type {!HTMLElement}
367    * @private
368    * @const
369    */
370   this.editButton_ = queryRequiredElement(this.toolbar_, 'button.edit');
371   this.editButton_.title = this.displayStringFunction_('GALLERY_EDIT');
372   this.editButton_.disabled = true;  // Disabled by default.
373   this.editButton_.addEventListener('click', this.toggleEditor.bind(this));
375   /**
376    * @type {!HTMLElement}
377    * @private
378    * @const
379    */
380   this.printButton_ = queryRequiredElement(this.toolbar_, 'button.print');
381   this.printButton_.title = this.displayStringFunction_('GALLERY_PRINT');
382   this.printButton_.disabled = true;  // Disabled by default.
383   this.printButton_.addEventListener('click', this.print_.bind(this));
385   /**
386    * @type {!HTMLElement}
387    * @private
388    * @const
389    */
390   this.editBarSpacer_ = queryRequiredElement(this.toolbar_, '.edit-bar-spacer');
392   /**
393    * @type {!HTMLElement}
394    * @private
395    * @const
396    */
397   this.editBarMain_ = util.createChild(this.editBarSpacer_, 'edit-main');
399   /**
400    * @type {!HTMLElement}
401    * @private
402    * @const
403    */
404   this.editBarMode_ = util.createChild(this.container_, 'edit-modal');
406   /**
407    * @type {!HTMLElement}
408    * @private
409    * @const
410    */
411   this.editBarModeWrapper_ = util.createChild(
412       this.editBarMode_, 'edit-modal-wrapper dimmable');
413   this.editBarModeWrapper_.hidden = true;
415   /**
416    * Objects supporting image display and editing.
417    * @type {!Viewport}
418    * @private
419    * @const
420    */
421   this.viewport_ = new Viewport();
423   /**
424    * @type {!ImageView}
425    * @private
426    * @const
427    */
428   this.imageView_ = new ImageView(
429       this.imageContainer_,
430       this.viewport_,
431       metadataModel);
433   /**
434    * @type {!ImageEditor}
435    * @private
436    * @const
437    */
438   this.editor_ = new ImageEditor(
439       this.viewport_,
440       this.imageView_,
441       this.prompt_,
442       {
443         root: this.container_,
444         image: this.imageContainer_,
445         toolbar: this.editBarMain_,
446         mode: this.editBarModeWrapper_
447       },
448       SlideMode.EDITOR_MODES,
449       this.displayStringFunction_,
450       this.onToolsVisibilityChanged_.bind(this));
452   /**
453    * @type {!TouchHandler}
454    * @private
455    * @const
456    */
457   this.touchHandlers_ = new TouchHandler(this.imageContainer_, this);
461  * List of available editor modes.
462  * @type {!Array.<ImageEditor.Mode>}
463  * @const
464  */
465 SlideMode.EDITOR_MODES = [
466   new ImageEditor.Mode.InstantAutofix(),
467   new ImageEditor.Mode.Crop(),
468   new ImageEditor.Mode.Exposure(),
469   new ImageEditor.Mode.OneClick(
470       'rotate_left', 'GALLERY_ROTATE_LEFT', new Command.Rotate(-1)),
471   new ImageEditor.Mode.OneClick(
472       'rotate_right', 'GALLERY_ROTATE_RIGHT', new Command.Rotate(1))
476  * Map of the key identifier and offset delta.
477  * @enum {!Array.<number>})
478  * @const
479  */
480 SlideMode.KEY_OFFSET_MAP = {
481   'Up': [0, 20],
482   'Down': [0, -20],
483   'Left': [20, 0],
484   'Right': [-20, 0]
488  * SlideMode extends cr.EventTarget.
489  */
490 SlideMode.prototype.__proto__ = cr.EventTarget.prototype;
493  * @return {string} Mode name.
494  */
495 SlideMode.prototype.getName = function() { return 'slide'; };
498  * @return {string} Mode title.
499  */
500 SlideMode.prototype.getTitle = function() { return 'GALLERY_SLIDE'; };
503  * @return {!Viewport} Viewport.
504  */
505 SlideMode.prototype.getViewport = function() { return this.viewport_; };
508  * Load items, display the selected item.
509  * @param {ImageRect} zoomFromRect Rectangle for zoom effect.
510  * @param {function()} displayCallback Called when the image is displayed.
511  * @param {function()} loadCallback Called when the image is displayed.
512  */
513 SlideMode.prototype.enter = function(
514     zoomFromRect, displayCallback, loadCallback) {
515   this.sequenceDirection_ = 0;
516   this.sequenceLength_ = 0;
518   // The latest |leave| call might have left the image animating. Remove it.
519   this.unloadImage_();
521   new Promise(function(fulfill) {
522     // If the items are empty, just show the error message.
523     if (this.getItemCount_() === 0) {
524       this.displayedItem_ = null;
525       //TODO(hirono) Show this message in the grid mode too.
526       this.errorBanner_.show('GALLERY_NO_IMAGES');
527       fulfill();
528       return;
529     }
531     // Remember the selection if it is empty or multiple. It will be restored
532     // in |leave| if the user did not changing the selection manually.
533     var currentSelection = this.selectionModel_.selectedIndexes;
534     if (currentSelection.length === 1)
535       this.savedSelection_ = null;
536     else
537       this.savedSelection_ = currentSelection;
539     // Ensure valid single selection.
540     // Note that the SlideMode object is not listening to selection change yet.
541     this.select(Math.max(0, this.getSelectedIndex()));
543     // Show the selected item ASAP, then complete the initialization
544     // (loading the ribbon thumbnails can take some time).
545     var selectedItem = this.getSelectedItem();
546     this.displayedItem_ = selectedItem;
548     // Load the image of the item.
549     this.loadItem_(
550         assert(selectedItem),
551         zoomFromRect ?
552             this.imageView_.createZoomEffect(zoomFromRect) :
553             new ImageView.Effect.None(),
554         displayCallback,
555         function(loadType, delay) {
556           fulfill(delay);
557         });
558   }.bind(this)).then(function(delay) {
559     // Turn the mode active.
560     this.active_ = true;
561     ImageUtil.setAttribute(this.arrowBox_, 'active', this.getItemCount_() > 1);
562     this.ribbon_.enable();
564     // Register handlers.
565     this.selectionModel_.addEventListener('change', this.onSelectionBound_);
566     this.dataModel_.addEventListener('splice', this.onSpliceBound_);
567     this.touchHandlers_.enabled = true;
569     // Wait 1000ms after the animation is done, then prefetch the next image.
570     this.requestPrefetch(1, delay + 1000);
572     // Call load callback.
573     if (loadCallback)
574       loadCallback();
575   }.bind(this)).catch(function(error) {
576     console.error(error.stack, error);
577   });
581  * Leave the mode.
582  * @param {ImageRect} zoomToRect Rectangle for zoom effect.
583  * @param {function()} callback Called when the image is committed and
584  *   the zoom-out animation has started.
585  */
586 SlideMode.prototype.leave = function(zoomToRect, callback) {
587   var commitDone = function() {
588     this.stopEditing_();
589     this.stopSlideshow_();
590     ImageUtil.setAttribute(this.arrowBox_, 'active', false);
591     this.selectionModel_.removeEventListener(
592         'change', this.onSelectionBound_);
593     this.dataModel_.removeEventListener('splice', this.onSpliceBound_);
594     this.ribbon_.disable();
595     this.active_ = false;
596     if (this.savedSelection_)
597       this.selectionModel_.selectedIndexes = this.savedSelection_;
598     this.unloadImage_(zoomToRect);
599     callback();
600   }.bind(this);
602   this.viewport_.resetView();
603   if (this.getItemCount_() === 0) {
604     this.errorBanner_.clear();
605     commitDone();
606   } else {
607     this.commitItem_(commitDone);
608   }
610   // Disable the slide-mode only buttons when leaving.
611   this.editButton_.disabled = true;
612   this.printButton_.disabled = true;
614   // Disable touch operation.
615   this.touchHandlers_.enabled = false;
620  * Execute an action when the editor is not busy.
622  * @param {function()} action Function to execute.
623  */
624 SlideMode.prototype.executeWhenReady = function(action) {
625   this.editor_.executeWhenReady(action);
629  * @return {boolean} True if the mode has active tools (that should not fade).
630  */
631 SlideMode.prototype.hasActiveTool = function() {
632   return this.isEditing();
636  * @return {number} Item count.
637  * @private
638  */
639 SlideMode.prototype.getItemCount_ = function() {
640   return this.dataModel_.length;
644  * @param {number} index Index.
645  * @return {Gallery.Item} Item.
646  */
647 SlideMode.prototype.getItem = function(index) {
648   var item =
649       /** @type {(Gallery.Item|undefined)} */ (this.dataModel_.item(index));
650   return item === undefined ? null : item;
654  * @return {number} Selected index.
655  */
656 SlideMode.prototype.getSelectedIndex = function() {
657   return this.selectionModel_.selectedIndex;
661  * @return {ImageRect} Screen rectangle of the selected image.
662  */
663 SlideMode.prototype.getSelectedImageRect = function() {
664   if (this.getSelectedIndex() < 0)
665     return null;
666   else
667     return this.viewport_.getImageBoundsOnScreen();
671  * @return {Gallery.Item} Selected item.
672  */
673 SlideMode.prototype.getSelectedItem = function() {
674   return this.getItem(this.getSelectedIndex());
678  * Toggles the full screen mode.
679  * @private
680  */
681 SlideMode.prototype.toggleFullScreen_ = function() {
682   util.toggleFullScreen(this.context_.appWindow,
683                         !util.isFullScreen(this.context_.appWindow));
687  * Selection change handler.
689  * Commits the current image and displays the newly selected image.
690  * @private
691  */
692 SlideMode.prototype.onSelection_ = function() {
693   if (this.selectionModel_.selectedIndexes.length === 0)
694     return;  // Ignore temporary empty selection.
696   // Forget the saved selection if the user changed the selection manually.
697   if (!this.isSlideshowOn_())
698     this.savedSelection_ = null;
700   if (this.getSelectedItem() === this.displayedItem_)
701     return;  // Do not reselect.
703   this.commitItem_(this.loadSelectedItem_.bind(this));
707  * Handles changes in tools visibility, and if the header is dimmed, then
708  * requests disabling the draggable app region.
710  * @private
711  */
712 SlideMode.prototype.onToolsVisibilityChanged_ = function() {
713   var headerDimmed = queryRequiredElement(this.document_, '.header')
714       .hasAttribute('dimmed');
715   this.context_.onAppRegionChanged(!headerDimmed);
719  * Change the selection.
721  * @param {number} index New selected index.
722  * @param {number=} opt_slideHint Slide animation direction (-1|1).
723  */
724 SlideMode.prototype.select = function(index, opt_slideHint) {
725   this.slideHint_ = opt_slideHint || null;
726   this.selectionModel_.selectedIndex = index;
727   this.selectionModel_.leadIndex = index;
731  * Load the selected item.
733  * @private
734  */
735 SlideMode.prototype.loadSelectedItem_ = function() {
736   var slideHint = this.slideHint_;
737   this.slideHint_ = null;
739   if (this.getSelectedItem() === this.displayedItem_)
740     return;  // Do not reselect.
742   var index = this.getSelectedIndex();
743   var displayedIndex = this.dataModel_.indexOf(this.displayedItem_);
744   var step =
745       slideHint || (displayedIndex > 0 ? index - displayedIndex : 1);
747   if (Math.abs(step) != 1) {
748     // Long leap, the sequence is broken, we have no good prefetch candidate.
749     this.sequenceDirection_ = 0;
750     this.sequenceLength_ = 0;
751   } else if (this.sequenceDirection_ === step) {
752     // Keeping going in sequence.
753     this.sequenceLength_++;
754   } else {
755     // Reversed the direction. Reset the counter.
756     this.sequenceDirection_ = step;
757     this.sequenceLength_ = 1;
758   }
760   this.displayedItem_ = this.getSelectedItem();
761   var selectedItem = assertInstanceof(this.getSelectedItem(), Gallery.Item);
763   function shouldPrefetch(loadType, step, sequenceLength) {
764     // Never prefetch when selecting out of sequence.
765     if (Math.abs(step) != 1)
766       return false;
768     // Always prefetch if the previous load was from cache.
769     if (loadType === ImageView.LoadType.CACHED_FULL)
770       return true;
772     // Prefetch if we have been going in the same direction for long enough.
773     return sequenceLength >= 3;
774   }
776   this.currentUniqueKey_++;
777   var selectedUniqueKey = this.currentUniqueKey_;
779   // Discard, since another load has been invoked after this one.
780   if (selectedUniqueKey != this.currentUniqueKey_)
781     return;
783   this.loadItem_(
784       selectedItem,
785       new ImageView.Effect.Slide(step, this.isSlideshowPlaying_()),
786       function() {} /* no displayCallback */,
787       function(loadType, delay) {
788         // Discard, since another load has been invoked after this one.
789         if (selectedUniqueKey != this.currentUniqueKey_)
790           return;
791         if (shouldPrefetch(loadType, step, this.sequenceLength_))
792           this.requestPrefetch(step, delay);
793         if (this.isSlideshowPlaying_())
794           this.scheduleNextSlide_();
795       }.bind(this));
799  * Unload the current image.
801  * @param {ImageRect=} opt_zoomToRect Rectangle for zoom effect.
802  * @private
803  */
804 SlideMode.prototype.unloadImage_ = function(opt_zoomToRect) {
805   this.imageView_.unload(opt_zoomToRect);
809  * Data model 'splice' event handler.
810  * @param {!Event} event Event.
811  * @this {SlideMode}
812  * @private
813  */
814 SlideMode.prototype.onSplice_ = function(event) {
815   ImageUtil.setAttribute(this.arrowBox_, 'active', this.getItemCount_() > 1);
817   // Splice invalidates saved indices, drop the saved selection.
818   this.savedSelection_ = null;
820   if (event.removed.length != 1)
821     return;
823   // Delay the selection to let the ribbon splice handler work first.
824   setTimeout(function() {
825     var displayedItemNotRemvoed = event.removed.every(function(item) {
826       return item !== this.displayedItem_;
827     }.bind(this));
828     if (displayedItemNotRemvoed)
829       return;
830     var nextIndex;
831     if (event.index < this.dataModel_.length) {
832       // There is the next item, select it.
833       // The next item is now at the same index as the removed one, so we need
834       // to correct displayIndex_ so that loadSelectedItem_ does not think
835       // we are re-selecting the same item (and does right-to-left slide-in
836       // animation).
837       nextIndex = event.index;
838     } else if (this.dataModel_.length) {
839       // Removed item is the rightmost, but there are more items.
840       nextIndex = event.index - 1;  // Select the new last index.
841     } else {
842       // No items left. Unload the image, disable edit and print button, and
843       // show the banner.
844       this.commitItem_(function() {
845         this.unloadImage_();
846         this.printButton_.disabled = true;
847         this.editButton_.disabled = true;
848         this.errorBanner_.show('GALLERY_NO_IMAGES');
849       }.bind(this));
850       return;
851     }
852     // To force to dispatch a selection change event, clear selection before.
853     this.selectionModel_.clear();
854     this.select(nextIndex);
855   }.bind(this), 0);
859  * @param {number} direction -1 for left, 1 for right.
860  * @return {number} Next index in the given direction, with wrapping.
861  * @private
862  */
863 SlideMode.prototype.getNextSelectedIndex_ = function(direction) {
864   function advance(index, limit) {
865     index += (direction > 0 ? 1 : -1);
866     if (index < 0)
867       return limit - 1;
868     if (index === limit)
869       return 0;
870     return index;
871   }
873   // If the saved selection is multiple the Slideshow should cycle through
874   // the saved selection.
875   if (this.isSlideshowOn_() &&
876       this.savedSelection_ && this.savedSelection_.length > 1) {
877     var pos = advance(this.savedSelection_.indexOf(this.getSelectedIndex()),
878         this.savedSelection_.length);
879     return this.savedSelection_[pos];
880   } else {
881     return advance(this.getSelectedIndex(), this.getItemCount_());
882   }
886  * Advance the selection based on the pressed key ID.
887  * @param {string} keyID Key identifier.
888  */
889 SlideMode.prototype.advanceWithKeyboard = function(keyID) {
890   var prev = (keyID === 'Up' ||
891               keyID === 'Left' ||
892               keyID === 'MediaPreviousTrack');
893   this.advanceManually(prev ? -1 : 1);
897  * Advance the selection as a result of a user action (as opposed to an
898  * automatic change in the slideshow mode).
899  * @param {number} direction -1 for left, 1 for right.
900  */
901 SlideMode.prototype.advanceManually = function(direction) {
902   if (this.isSlideshowPlaying_())
903     this.pauseSlideshow_();
904   cr.dispatchSimpleEvent(this, 'useraction');
905   this.selectNext(direction);
909  * Select the next item.
910  * @param {number} direction -1 for left, 1 for right.
911  */
912 SlideMode.prototype.selectNext = function(direction) {
913   this.select(this.getNextSelectedIndex_(direction), direction);
917  * Select the first item.
918  */
919 SlideMode.prototype.selectFirst = function() {
920   this.select(0);
924  * Select the last item.
925  */
926 SlideMode.prototype.selectLast = function() {
927   this.select(this.getItemCount_() - 1);
930 // Loading/unloading
933  * Load and display an item.
935  * @param {!Gallery.Item} item Item.
936  * @param {!ImageView.Effect} effect Transition effect object.
937  * @param {function()} displayCallback Called when the image is displayed
938  *     (which can happen before the image load due to caching).
939  * @param {function(number, number)} loadCallback Called when the image is fully
940  *     loaded.
941  * @private
942  */
943 SlideMode.prototype.loadItem_ = function(
944     item, effect, displayCallback, loadCallback) {
945   this.showSpinner_(true);
947   var loadDone = this.itemLoaded_.bind(this, item, loadCallback);
949   var displayDone = function() {
950     cr.dispatchSimpleEvent(this, 'image-displayed');
951     displayCallback();
952   }.bind(this);
954   this.editor_.openSession(
955       item,
956       effect,
957       this.saveCurrentImage_.bind(this, item),
958       displayDone,
959       loadDone);
963  * A callback function when the editor opens a editing session for an image.
964  * @param {!Gallery.Item} item Gallery item.
965  * @param {function(number, number)} loadCallback Called when the image is fully
966  *     loaded.
967  * @param {number} loadType Load type.
968  * @param {number} delay Delay.
969  * @param {*=} opt_error Error.
970  * @private
971  */
972 SlideMode.prototype.itemLoaded_ = function(
973     item, loadCallback, loadType, delay, opt_error) {
974   var entry = item.getEntry();
976   this.showSpinner_(false);
977   if (loadType === ImageView.LoadType.ERROR) {
978     // if we have a specific error, then display it
979     if (opt_error) {
980       this.errorBanner_.show(/** @type {string} */ (opt_error));
981     } else {
982       // otherwise try to infer general error
983       this.errorBanner_.show('GALLERY_IMAGE_ERROR');
984     }
985   } else if (loadType === ImageView.LoadType.OFFLINE) {
986     this.errorBanner_.show('GALLERY_IMAGE_OFFLINE');
987   }
989   ImageUtil.metrics.recordUserAction(ImageUtil.getMetricName('View'));
991   var toMillions = function(number) {
992     return Math.round(number / (1000 * 1000));
993   };
995   var metadata = item.getMetadataItem();
996   if (metadata) {
997     ImageUtil.metrics.recordSmallCount(ImageUtil.getMetricName('Size.MB'),
998         toMillions(metadata.size));
999   }
1001   var canvas = this.imageView_.getCanvas();
1002   ImageUtil.metrics.recordSmallCount(ImageUtil.getMetricName('Size.MPix'),
1003       toMillions(canvas.width * canvas.height));
1005   var extIndex = entry.name.lastIndexOf('.');
1006   var ext = extIndex < 0 ? '' :
1007       entry.name.substr(extIndex + 1).toLowerCase();
1008   if (ext === 'jpeg') ext = 'jpg';
1009   ImageUtil.metrics.recordEnum(
1010       ImageUtil.getMetricName('FileType'), ext, ImageUtil.FILE_TYPES);
1012   // Enable or disable buttons for editing and printing.
1013   if (opt_error) {
1014     this.editButton_.disabled = true;
1015     this.printButton_.disabled = true;
1016   } else {
1017     this.editButton_.disabled = false;
1018     this.printButton_.disabled = false;
1019   }
1021   // For once edited image, disallow the 'overwrite' setting change.
1022   ImageUtil.setAttribute(this.overwriteOriginalBox_, 'disabled',
1023       !this.getSelectedItem().isOriginal() || FileType.isRaw(item.getEntry()));
1025   var keys = {};
1026   keys[SlideMode.OVERWRITE_BUBBLE_KEY] = 0;
1027   keys[SlideMode.OVERWRITE_KEY] = true;
1028   chrome.storage.local.get(keys,
1029       function(values) {
1030         var times = values[SlideMode.OVERWRITE_BUBBLE_KEY];
1031         if (times < SlideMode.OVERWRITE_BUBBLE_MAX_TIMES) {
1032           this.bubble_.hidden = false;
1033           if (this.isEditing()) {
1034             var items = {};
1035             items[SlideMode.OVERWRITE_BUBBLE_KEY] = times + 1;
1036             chrome.storage.local.set(items);
1037           }
1038         }
1039         if (FileType.isRaw(item.getEntry()))
1040           this.overwriteOriginal_.checked = false;
1041         else
1042           this.overwriteOriginal_.checked = values[SlideMode.OVERWRITE_KEY];
1043       }.bind(this));
1045   loadCallback(loadType, delay);
1049  * Commit changes to the current item and reset all messages/indicators.
1051  * @param {function()} callback Callback.
1052  * @private
1053  */
1054 SlideMode.prototype.commitItem_ = function(callback) {
1055   this.showSpinner_(false);
1056   this.errorBanner_.clear();
1057   this.editor_.getPrompt().hide();
1058   this.editor_.closeSession(callback);
1062  * Request a prefetch for the next image.
1064  * @param {number} direction -1 or 1.
1065  * @param {number} delay Delay in ms. Used to prevent the CPU-heavy image
1066  *   loading from disrupting the animation that might be still in progress.
1067  */
1068 SlideMode.prototype.requestPrefetch = function(direction, delay) {
1069   if (this.getItemCount_() <= 1) return;
1071   var index = this.getNextSelectedIndex_(direction);
1072   this.imageView_.prefetch(assert(this.getItem(index)), delay);
1075 // Event handlers.
1078  * Click handler for the image container.
1080  * @param {!Event} event Mouse click event.
1081  * @private
1082  */
1083 SlideMode.prototype.onClick_ = function(event) {
1087  * Click handler for the entire document.
1088  * @param {!Event} event Mouse click event.
1089  * @private
1090  */
1091 SlideMode.prototype.onDocumentClick_ = function(event) {
1092   // Events created in fakeMouseClick in test util don't pass this test.
1093   if (!window.IN_TEST)
1094     event = assertInstanceof(event, MouseEvent);
1096   var targetElement = assertInstanceof(event.target, HTMLElement);
1097   // Close the bubble if clicked outside of it and if it is visible.
1098   if (!this.bubble_.contains(targetElement) &&
1099       !this.editButton_.contains(targetElement) &&
1100       !this.arrowLeft_.contains(targetElement) &&
1101       !this.arrowRight_.contains(targetElement) &&
1102       !this.bubble_.hidden) {
1103     this.bubble_.hidden = true;
1104   }
1108  * Keydown handler.
1110  * @param {!Event} event Event.
1111  * @return {boolean} True if handled.
1112  */
1113 SlideMode.prototype.onKeyDown = function(event) {
1114   var keyID = util.getKeyModifiers(event) + event.keyIdentifier;
1116   if (this.isSlideshowOn_()) {
1117     switch (keyID) {
1118       case 'U+001B':  // Escape exits the slideshow.
1119       case 'MediaStop':
1120         this.stopSlideshow_(event);
1121         break;
1123       case 'U+0020':  // Space pauses/resumes the slideshow.
1124       case 'MediaPlayPause':
1125         this.toggleSlideshowPause_();
1126         break;
1128       case 'Up':
1129       case 'Down':
1130       case 'Left':
1131       case 'Right':
1132       case 'MediaNextTrack':
1133       case 'MediaPreviousTrack':
1134         this.advanceWithKeyboard(keyID);
1135         break;
1136     }
1137     return true;  // Consume all keystrokes in the slideshow mode.
1138   }
1140   if (this.isEditing() && this.editor_.onKeyDown(event))
1141     return true;
1143   switch (keyID) {
1144     case 'Ctrl-U+0050':  // Ctrl+'p' prints the current image.
1145       if (!this.printButton_.disabled)
1146         this.print_();
1147       break;
1149     case 'U+0045':  // 'e' toggles the editor.
1150       if (!this.editButton_.disabled)
1151         this.toggleEditor(event);
1152       break;
1154     case 'U+001B':  // Escape
1155       if (this.isEditing()) {
1156         this.toggleEditor(event);
1157       } else if (this.viewport_.isZoomed()) {
1158         this.viewport_.resetView();
1159         this.touchHandlers_.stopOperation();
1160         this.imageView_.applyViewportChange();
1161       } else {
1162         return false;  // Not handled.
1163       }
1164       break;
1166     case 'Home':
1167       this.selectFirst();
1168       break;
1169     case 'End':
1170       this.selectLast();
1171       break;
1172     case 'Up':
1173     case 'Down':
1174     case 'Left':
1175     case 'Right':
1176       if (!this.isEditing() && this.viewport_.isZoomed()) {
1177         var delta = SlideMode.KEY_OFFSET_MAP[keyID];
1178         this.viewport_.setOffset(
1179             ~~(this.viewport_.getOffsetX() +
1180                delta[0] * this.viewport_.getZoom()),
1181             ~~(this.viewport_.getOffsetY() +
1182                delta[1] * this.viewport_.getZoom()));
1183         this.touchHandlers_.stopOperation();
1184         this.imageView_.applyViewportChange();
1185       } else {
1186         this.advanceWithKeyboard(keyID);
1187       }
1188       break;
1189     case 'MediaNextTrack':
1190     case 'MediaPreviousTrack':
1191       this.advanceWithKeyboard(keyID);
1192       break;
1194     case 'Ctrl-U+00BB':  // Ctrl+'=' zoom in.
1195       if (!this.isEditing()) {
1196         this.viewport_.zoomIn();
1197         this.touchHandlers_.stopOperation();
1198         this.imageView_.applyViewportChange();
1199       }
1200       break;
1202     case 'Ctrl-U+00BD':  // Ctrl+'-' zoom out.
1203       if (!this.isEditing()) {
1204         this.viewport_.zoomOut();
1205         this.touchHandlers_.stopOperation();
1206         this.imageView_.applyViewportChange();
1207       }
1208       break;
1210     case 'Ctrl-U+0030': // Ctrl+'0' zoom reset.
1211       if (!this.isEditing()) {
1212         this.viewport_.setZoom(1.0);
1213         this.touchHandlers_.stopOperation();
1214         this.imageView_.applyViewportChange();
1215       }
1216       break;
1217   }
1219   return true;
1223  * Resize handler.
1224  * @private
1225  */
1226 SlideMode.prototype.onResize_ = function() {
1227   this.viewport_.setScreenSize(
1228       this.container_.clientWidth, this.container_.clientHeight);
1229   this.touchHandlers_.stopOperation();
1230   this.editor_.getBuffer().draw();
1234  * Update thumbnails.
1235  */
1236 SlideMode.prototype.updateThumbnails = function() {
1237   this.ribbon_.reset();
1238   if (this.active_)
1239     this.ribbon_.redraw();
1242 // Saving
1245  * Save the current image to a file.
1247  * @param {!Gallery.Item} item Item to save the image.
1248  * @param {function()} callback Callback.
1249  * @private
1250  */
1251 SlideMode.prototype.saveCurrentImage_ = function(item, callback) {
1252   this.showSpinner_(true);
1254   var savedPromise = this.dataModel_.saveItem(
1255       this.volumeManager_,
1256       item,
1257       this.imageView_.getCanvas(),
1258       this.shouldOverwriteOriginal_());
1260   savedPromise.then(function() {
1261     this.showSpinner_(false);
1262     this.flashSavedLabel_();
1264     // Allow changing the 'Overwrite original' setting only if the user
1265     // used Undo to restore the original image AND it is not a copy.
1266     // Otherwise lock the setting in its current state.
1267     var mayChangeOverwrite = !this.editor_.canUndo() && item.isOriginal() &&
1268         !FileType.isRaw(item.getEntry());
1269     ImageUtil.setAttribute(
1270         this.overwriteOriginalBox_, 'disabled', !mayChangeOverwrite);
1272     // Record UMA for the first edit.
1273     if (this.imageView_.getContentRevision() === 1)
1274       ImageUtil.metrics.recordUserAction(ImageUtil.getMetricName('Edit'));
1276     callback();
1277   }.bind(this)).catch(function(error) {
1278     console.error(error.stack || error);
1280     this.showSpinner_(false);
1281     this.errorBanner_.show('GALLERY_SAVE_FAILED');
1283     callback();
1284   }.bind(this));
1288  * Flash 'Saved' label briefly to indicate that the image has been saved.
1289  * @private
1290  */
1291 SlideMode.prototype.flashSavedLabel_ = function() {
1292   var setLabelHighlighted =
1293       ImageUtil.setAttribute.bind(null, this.savedLabel_, 'highlighted');
1294   setTimeout(setLabelHighlighted.bind(null, true), 0);
1295   setTimeout(setLabelHighlighted.bind(null, false), 300);
1299  * Local storage key for the 'Overwrite original' setting.
1300  * @type {string}
1301  */
1302 SlideMode.OVERWRITE_KEY = 'gallery-overwrite-original';
1305  * Local storage key for the number of times that
1306  * the overwrite info bubble has been displayed.
1307  * @type {string}
1308  */
1309 SlideMode.OVERWRITE_BUBBLE_KEY = 'gallery-overwrite-bubble';
1312  * Max number that the overwrite info bubble is shown.
1313  * @type {number}
1314  */
1315 SlideMode.OVERWRITE_BUBBLE_MAX_TIMES = 5;
1318  * @return {boolean} True if 'Overwrite original' is set.
1319  * @private
1320  */
1321 SlideMode.prototype.shouldOverwriteOriginal_ = function() {
1322   return this.overwriteOriginal_.checked;
1326  * 'Overwrite original' checkbox handler.
1327  * @param {!Event} event Event.
1328  * @private
1329  */
1330 SlideMode.prototype.onOverwriteOriginalClick_ = function(event) {
1331   var items = {};
1332   items[SlideMode.OVERWRITE_KEY] = event.target.checked;
1333   chrome.storage.local.set(items);
1337  * Overwrite info bubble close handler.
1338  * @private
1339  */
1340 SlideMode.prototype.onCloseBubble_ = function() {
1341   this.bubble_.hidden = true;
1342   var items = {};
1343   items[SlideMode.OVERWRITE_BUBBLE_KEY] =
1344       SlideMode.OVERWRITE_BUBBLE_MAX_TIMES;
1345   chrome.storage.local.set(items);
1348 // Slideshow
1351  * Slideshow interval in ms.
1352  */
1353 SlideMode.SLIDESHOW_INTERVAL = 5000;
1356  * First slideshow interval in ms. It should be shorter so that the user
1357  * is not guessing whether the button worked.
1358  */
1359 SlideMode.SLIDESHOW_INTERVAL_FIRST = 1000;
1362  * Empirically determined duration of the fullscreen toggle animation.
1363  */
1364 SlideMode.FULLSCREEN_TOGGLE_DELAY = 500;
1367  * @return {boolean} True if the slideshow is on.
1368  * @private
1369  */
1370 SlideMode.prototype.isSlideshowOn_ = function() {
1371   return this.container_.hasAttribute('slideshow');
1375  * Starts the slideshow.
1376  * @param {number=} opt_interval First interval in ms.
1377  * @param {Event=} opt_event Event.
1378  */
1379 SlideMode.prototype.startSlideshow = function(opt_interval, opt_event) {
1380   // Reset zoom.
1381   this.viewport_.resetView();
1382   this.imageView_.applyViewportChange();
1384   // Disable touch operation.
1385   this.touchHandlers_.enabled = false;
1387   // Set the attribute early to prevent the toolbar from flashing when
1388   // the slideshow is being started from the mosaic view.
1389   this.container_.setAttribute('slideshow', 'playing');
1391   if (this.active_) {
1392     this.stopEditing_();
1393   } else {
1394     // We are in the Mosaic mode. Toggle the mode but remember to return.
1395     this.leaveAfterSlideshow_ = true;
1397     // Wait until the zoom animation from the mosaic mode is done.
1398     var startSlideshowAfterTransition = function() {
1399       setTimeout(function() {
1400         this.startSlideshow.call(this, SlideMode.SLIDESHOW_INTERVAL, opt_event);
1401       }.bind(this), ImageView.MODE_TRANSITION_DURATION);
1402     }.bind(this);
1403     this.toggleMode_(startSlideshowAfterTransition);
1404     return;
1405   }
1407   if (opt_event)  // Caused by user action, notify the Gallery.
1408     cr.dispatchSimpleEvent(this, 'useraction');
1410   this.fullscreenBeforeSlideshow_ = util.isFullScreen(this.context_.appWindow);
1411   if (!this.fullscreenBeforeSlideshow_) {
1412     this.toggleFullScreen_();
1413     opt_interval = (opt_interval || SlideMode.SLIDESHOW_INTERVAL) +
1414         SlideMode.FULLSCREEN_TOGGLE_DELAY;
1415   }
1417   this.resumeSlideshow_(opt_interval);
1421  * Stops the slideshow.
1422  * @param {Event=} opt_event Event.
1423  * @private
1424  */
1425 SlideMode.prototype.stopSlideshow_ = function(opt_event) {
1426   if (!this.isSlideshowOn_())
1427     return;
1429   if (opt_event)  // Caused by user action, notify the Gallery.
1430     cr.dispatchSimpleEvent(this, 'useraction');
1432   this.pauseSlideshow_();
1433   this.container_.removeAttribute('slideshow');
1435   // Do not restore fullscreen if we exited fullscreen while in slideshow.
1436   var fullscreen = util.isFullScreen(this.context_.appWindow);
1437   var toggleModeDelay = 0;
1438   if (!this.fullscreenBeforeSlideshow_ && fullscreen) {
1439     this.toggleFullScreen_();
1440     toggleModeDelay = SlideMode.FULLSCREEN_TOGGLE_DELAY;
1441   }
1442   if (this.leaveAfterSlideshow_) {
1443     this.leaveAfterSlideshow_ = false;
1444     setTimeout(this.toggleMode_.bind(this), toggleModeDelay);
1445   }
1447   // Re-enable touch operation.
1448   this.touchHandlers_.enabled = true;
1452  * @return {boolean} True if the slideshow is playing (not paused).
1453  * @private
1454  */
1455 SlideMode.prototype.isSlideshowPlaying_ = function() {
1456   return this.container_.getAttribute('slideshow') === 'playing';
1460  * Pauses/resumes the slideshow.
1461  * @private
1462  */
1463 SlideMode.prototype.toggleSlideshowPause_ = function() {
1464   cr.dispatchSimpleEvent(this, 'useraction');  // Show the tools.
1465   if (this.isSlideshowPlaying_()) {
1466     this.pauseSlideshow_();
1467   } else {
1468     this.resumeSlideshow_(SlideMode.SLIDESHOW_INTERVAL_FIRST);
1469   }
1473  * @param {number=} opt_interval Slideshow interval in ms.
1474  * @private
1475  */
1476 SlideMode.prototype.scheduleNextSlide_ = function(opt_interval) {
1477   console.assert(this.isSlideshowPlaying_(), 'Inconsistent slideshow state');
1479   if (this.slideShowTimeout_)
1480     clearTimeout(this.slideShowTimeout_);
1482   this.slideShowTimeout_ = setTimeout(function() {
1483     this.slideShowTimeout_ = null;
1484     this.selectNext(1);
1485   }.bind(this), opt_interval || SlideMode.SLIDESHOW_INTERVAL);
1489  * Resumes the slideshow.
1490  * @param {number=} opt_interval Slideshow interval in ms.
1491  * @private
1492  */
1493 SlideMode.prototype.resumeSlideshow_ = function(opt_interval) {
1494   this.container_.setAttribute('slideshow', 'playing');
1495   this.scheduleNextSlide_(opt_interval);
1499  * Pauses the slideshow.
1500  * @private
1501  */
1502 SlideMode.prototype.pauseSlideshow_ = function() {
1503   this.container_.setAttribute('slideshow', 'paused');
1504   if (this.slideShowTimeout_) {
1505     clearTimeout(this.slideShowTimeout_);
1506     this.slideShowTimeout_ = null;
1507   }
1511  * @return {boolean} True if the editor is active.
1512  */
1513 SlideMode.prototype.isEditing = function() {
1514   return this.container_.hasAttribute('editing');
1518  * Stops editing.
1519  * @private
1520  */
1521 SlideMode.prototype.stopEditing_ = function() {
1522   if (this.isEditing())
1523     this.toggleEditor();
1527  * Activate/deactivate editor.
1528  * @param {Event=} opt_event Event.
1529  */
1530 SlideMode.prototype.toggleEditor = function(opt_event) {
1531   if (opt_event)  // Caused by user action, notify the Gallery.
1532     cr.dispatchSimpleEvent(this, 'useraction');
1534   if (!this.active_) {
1535     this.toggleMode_(this.toggleEditor.bind(this));
1536     return;
1537   }
1539   this.stopSlideshow_();
1541   ImageUtil.setAttribute(this.container_, 'editing', !this.isEditing());
1543   if (this.isEditing()) { // isEditing has just been flipped to a new value.
1544     // Reset zoom.
1545     this.viewport_.resetView();
1546     this.imageView_.applyViewportChange();
1547     if (this.context_.readonlyDirName) {
1548       this.editor_.getPrompt().showAt(
1549           'top', 'GALLERY_READONLY_WARNING', 0, this.context_.readonlyDirName);
1550     }
1551     this.touchHandlers_.enabled = false;
1552   } else {
1553     this.editor_.getPrompt().hide();
1554     this.editor_.leaveModeGently();
1555     this.touchHandlers_.enabled = true;
1556   }
1560  * Prints the current item.
1561  * @private
1562  */
1563 SlideMode.prototype.print_ = function() {
1564   cr.dispatchSimpleEvent(this, 'useraction');
1565   window.print();
1569  * Shows/hides the busy spinner.
1571  * @param {boolean} on True if show, false if hide.
1572  * @private
1573  */
1574 SlideMode.prototype.showSpinner_ = function(on) {
1575   if (this.spinnerTimer_) {
1576     clearTimeout(this.spinnerTimer_);
1577     this.spinnerTimer_ = null;
1578   }
1580   if (on) {
1581     this.spinnerTimer_ = setTimeout(function() {
1582       this.spinnerTimer_ = null;
1583       ImageUtil.setAttribute(this.container_, 'spinner', true);
1584     }.bind(this), 1000);
1585   } else {
1586     ImageUtil.setAttribute(this.container_, 'spinner', false);
1587   }
1591  * Apply the change of viewport.
1592  */
1593 SlideMode.prototype.applyViewportChange = function() {
1594   this.imageView_.applyViewportChange();
1598  * Touch handlers of the slide mode.
1599  * @param {!Element} targetElement Event source.
1600  * @param {!SlideMode} slideMode Slide mode to be operated by the handler.
1601  * @struct
1602  * @constructor
1603  */
1604 function TouchHandler(targetElement, slideMode) {
1605   /**
1606    * Event source.
1607    * @type {!Element}
1608    * @private
1609    * @const
1610    */
1611   this.targetElement_ = targetElement;
1613   /**
1614    * Target of touch operations.
1615    * @type {!SlideMode}
1616    * @private
1617    * @const
1618    */
1619   this.slideMode_ = slideMode;
1621   /**
1622    * Flag to enable/disable touch operation.
1623    * @type {boolean}
1624    * @private
1625    */
1626   this.enabled_ = true;
1628   /**
1629    * Whether it is in a touch operation that is started from targetElement or
1630    * not.
1631    * @type {boolean}
1632    * @private
1633    */
1634   this.touchStarted_ = false;
1636   /**
1637    * The swipe action that should happen only once in an operation is already
1638    * done or not.
1639    * @type {boolean}
1640    * @private
1641    */
1642   this.done_ = false;
1644   /**
1645    * Event on beginning of the current gesture.
1646    * The variable is updated when the number of touch finger changed.
1647    * @type {TouchEvent}
1648    * @private
1649    */
1650   this.gestureStartEvent_ = null;
1652   /**
1653    * Rotation value on beginning of the current gesture.
1654    * @type {number}
1655    * @private
1656    */
1657   this.gestureStartRotation_ = 0;
1659   /**
1660    * Last touch event.
1661    * @type {TouchEvent}
1662    * @private
1663    */
1664   this.lastEvent_ = null;
1666   /**
1667    * Zoom value just after last touch event.
1668    * @type {number}
1669    * @private
1670    */
1671   this.lastZoom_ = 1.0;
1673   targetElement.addEventListener('touchstart', this.onTouchStart_.bind(this));
1674   var onTouchEventBound = this.onTouchEvent_.bind(this);
1675   targetElement.ownerDocument.addEventListener('touchmove', onTouchEventBound);
1676   targetElement.ownerDocument.addEventListener('touchend', onTouchEventBound);
1678   targetElement.addEventListener('mousewheel', this.onMouseWheel_.bind(this));
1682  * If the user touched the image and moved the finger more than SWIPE_THRESHOLD
1683  * horizontally it's considered as a swipe gesture (change the current image).
1684  * @type {number}
1685  * @const
1686  */
1687 TouchHandler.SWIPE_THRESHOLD = 100;
1690  * Rotation threshold in degrees.
1691  * @type {number}
1692  * @const
1693  */
1694 TouchHandler.ROTATION_THRESHOLD = 25;
1697  * Obtains distance between fingers.
1698  * @param {!TouchEvent} event Touch event. It should include more than two
1699  *     touches.
1700  * @return {number} Distance between touch[0] and touch[1].
1701  */
1702 TouchHandler.getDistance = function(event) {
1703   var touch1 = event.touches[0];
1704   var touch2 = event.touches[1];
1705   var dx = touch1.clientX - touch2.clientX;
1706   var dy = touch1.clientY - touch2.clientY;
1707   return Math.sqrt(dx * dx + dy * dy);
1711  * Obtains the degrees of the pinch twist angle.
1712  * @param {!TouchEvent} event1 Start touch event. It should include more than
1713  *     two touches.
1714  * @param {!TouchEvent} event2 Current touch event. It should include more than
1715  *     two touches.
1716  * @return {number} Degrees of the pinch twist angle.
1717  */
1718 TouchHandler.getTwistAngle = function(event1, event2) {
1719   var dx1 = event1.touches[1].clientX - event1.touches[0].clientX;
1720   var dy1 = event1.touches[1].clientY - event1.touches[0].clientY;
1721   var dx2 = event2.touches[1].clientX - event2.touches[0].clientX;
1722   var dy2 = event2.touches[1].clientY - event2.touches[0].clientY;
1723   var innerProduct = dx1 * dx2 + dy1 * dy2;  // |v1| * |v2| * cos(t) = x / r
1724   var outerProduct = dx1 * dy2 - dy1 * dx2;  // |v1| * |v2| * sin(t) = y / r
1725   return Math.atan2(outerProduct, innerProduct) * 180 / Math.PI;  // atan(y / x)
1728 TouchHandler.prototype = /** @struct */ {
1729   /**
1730    * @param {boolean} flag New value.
1731    */
1732   set enabled(flag) {
1733     this.enabled_ = flag;
1734     if (!this.enabled_)
1735       this.stopOperation();
1736   }
1740  * Stops the current touch operation.
1741  */
1742 TouchHandler.prototype.stopOperation = function() {
1743   this.touchStarted_ = false;
1744   this.done_ = false;
1745   this.gestureStartEvent_ = null;
1746   this.lastEvent_ = null;
1747   this.lastZoom_ = 1.0;
1751  * Handles touch start events.
1752  * @param {!Event} event Touch event.
1753  * @private
1754  */
1755 TouchHandler.prototype.onTouchStart_ = function(event) {
1756   event = assertInstanceof(event, TouchEvent);
1757   if (this.enabled_ && event.touches.length === 1)
1758     this.touchStarted_ = true;
1762  * Handles touch move and touch end events.
1763  * @param {!Event} event Touch event.
1764  * @private
1765  */
1766 TouchHandler.prototype.onTouchEvent_ = function(event) {
1767   event = assertInstanceof(event, TouchEvent);
1768   // Check if the current touch operation started from the target element or
1769   // not.
1770   if (!this.touchStarted_)
1771     return;
1773   // Check if the current touch operation ends with the event.
1774   if (event.touches.length === 0) {
1775     this.stopOperation();
1776     return;
1777   }
1779   // Check if a new gesture started or not.
1780   var viewport = this.slideMode_.getViewport();
1781   if (!this.lastEvent_ ||
1782       this.lastEvent_.touches.length !== event.touches.length) {
1783     if (event.touches.length === 2 ||
1784         event.touches.length === 1) {
1785       this.gestureStartEvent_ = event;
1786       this.gestureStartRotation_ = viewport.getRotation();
1787       this.lastEvent_ = event;
1788       this.lastZoom_ = viewport.getZoom();
1789     } else {
1790       this.gestureStartEvent_ = null;
1791       this.gestureStartRotation_ = 0;
1792       this.lastEvent_ = null;
1793       this.lastZoom_ = 1.0;
1794     }
1795     return;
1796   }
1798   // Handle the gesture movement.
1799   switch (event.touches.length) {
1800     case 1:
1801       if (viewport.isZoomed()) {
1802         // Scrolling an image by swipe.
1803         var dx = event.touches[0].screenX - this.lastEvent_.touches[0].screenX;
1804         var dy = event.touches[0].screenY - this.lastEvent_.touches[0].screenY;
1805         viewport.setOffset(
1806             viewport.getOffsetX() + dx, viewport.getOffsetY() + dy);
1807         this.slideMode_.applyViewportChange();
1808       } else {
1809         // Traversing images by swipe.
1810         if (this.done_)
1811           break;
1812         var dx =
1813             event.touches[0].clientX -
1814             this.gestureStartEvent_.touches[0].clientX;
1815         if (dx > TouchHandler.SWIPE_THRESHOLD) {
1816           this.slideMode_.advanceManually(-1);
1817           this.done_ = true;
1818         } else if (dx < -TouchHandler.SWIPE_THRESHOLD) {
1819           this.slideMode_.advanceManually(1);
1820           this.done_ = true;
1821         }
1822       }
1823       break;
1825     case 2:
1826       // Pinch zoom.
1827       var distance1 = TouchHandler.getDistance(this.lastEvent_);
1828       var distance2 = TouchHandler.getDistance(event);
1829       if (distance1 === 0)
1830         break;
1831       var zoom = distance2 / distance1 * this.lastZoom_;
1832       viewport.setZoom(zoom);
1834       // Pinch rotation.
1835       assert(this.gestureStartEvent_);
1836       var angle = TouchHandler.getTwistAngle(this.gestureStartEvent_, event);
1837       if (angle > TouchHandler.ROTATION_THRESHOLD)
1838         viewport.setRotation(this.gestureStartRotation_ + 1);
1839       else if (angle < -TouchHandler.ROTATION_THRESHOLD)
1840         viewport.setRotation(this.gestureStartRotation_ - 1);
1841       else
1842         viewport.setRotation(this.gestureStartRotation_);
1843       this.slideMode_.applyViewportChange();
1844       break;
1845   }
1847   // Update the last event.
1848   this.lastEvent_ = event;
1849   this.lastZoom_ = viewport.getZoom();
1853  * Handles mouse wheel events.
1854  * @param {!Event} event Wheel event.
1855  * @private
1856  */
1857 TouchHandler.prototype.onMouseWheel_ = function(event) {
1858   var event = assertInstanceof(event, MouseEvent);
1859   var viewport = this.slideMode_.getViewport();
1860   if (!this.enabled_ || !viewport.isZoomed())
1861     return;
1862   this.stopOperation();
1863   viewport.setOffset(
1864       viewport.getOffsetX() + event.wheelDeltaX,
1865       viewport.getOffsetY() + event.wheelDeltaY);
1866   this.slideMode_.applyViewportChange();