Home | History | Annotate | Download | only in js
      1 // Copyright 2014 The Chromium Authors. All rights reserved.
      2 // Use of this source code is governed by a BSD-style license that can be
      3 // found in the LICENSE file.
      4 
      5 'use strict';
      6 
      7 /**
      8  * 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  * @param {Element} container Main container element.
     12  * @param {Element} content Content container element.
     13  * @param {Element} toolbar Toolbar element.
     14  * @param {ImageEditor.Prompt} prompt Prompt.
     15  * @param {cr.ui.ArrayDataModel} dataModel Data model.
     16  * @param {cr.ui.ListSelectionModel} selectionModel Selection model.
     17  * @param {Object} context Context.
     18  * @param {VolumeManager} volumeManager Volume manager.
     19  * @param {function(function())} toggleMode Function to toggle the Gallery mode.
     20  * @param {function(string):string} displayStringFunction String formatting
     21  *     function.
     22  * @constructor
     23  */
     24 function SlideMode(container, content, toolbar, prompt, dataModel,
     25     selectionModel, context, volumeManager, toggleMode, displayStringFunction) {
     26   this.container_ = container;
     27   this.document_ = container.ownerDocument;
     28   this.content = content;
     29   this.toolbar_ = toolbar;
     30   this.prompt_ = prompt;
     31   this.dataModel_ = dataModel;
     32   this.selectionModel_ = selectionModel;
     33   this.context_ = context;
     34   this.volumeManager_ = volumeManager;
     35   this.metadataCache_ = context.metadataCache;
     36   this.toggleMode_ = toggleMode;
     37   this.displayStringFunction_ = displayStringFunction;
     38 
     39   this.onSelectionBound_ = this.onSelection_.bind(this);
     40   this.onSpliceBound_ = this.onSplice_.bind(this);
     41 
     42   // Unique numeric key, incremented per each load attempt used to discard
     43   // old attempts. This can happen especially when changing selection fast or
     44   // Internet connection is slow.
     45   this.currentUniqueKey_ = 0;
     46 
     47   this.initListeners_();
     48   this.initDom_();
     49 }
     50 
     51 /**
     52  * List of available editor modes.
     53  * @type {Array.<ImageEditor.Mode>}
     54  * @const
     55  */
     56 SlideMode.EDITOR_MODES = Object.freeze([
     57   new ImageEditor.Mode.InstantAutofix(),
     58   new ImageEditor.Mode.Crop(),
     59   new ImageEditor.Mode.Exposure(),
     60   new ImageEditor.Mode.OneClick(
     61       'rotate_left', 'GALLERY_ROTATE_LEFT', new Command.Rotate(-1)),
     62   new ImageEditor.Mode.OneClick(
     63       'rotate_right', 'GALLERY_ROTATE_RIGHT', new Command.Rotate(1))
     64 ]);
     65 
     66 /**
     67  * Map of the key identifier and offset delta.
     68  * @type {Object.<string, Array.<number>})
     69  * @const
     70  */
     71 SlideMode.KEY_OFFSET_MAP = Object.freeze({
     72   'Up': Object.freeze([0, 20]),
     73   'Down': Object.freeze([0, -20]),
     74   'Left': Object.freeze([20, 0]),
     75   'Right': Object.freeze([-20, 0])
     76 });
     77 
     78 /**
     79  * SlideMode extends cr.EventTarget.
     80  */
     81 SlideMode.prototype.__proto__ = cr.EventTarget.prototype;
     82 
     83 /**
     84  * @return {string} Mode name.
     85  */
     86 SlideMode.prototype.getName = function() { return 'slide'; };
     87 
     88 /**
     89  * @return {string} Mode title.
     90  */
     91 SlideMode.prototype.getTitle = function() { return 'GALLERY_SLIDE'; };
     92 
     93 /**
     94  * @return {Viewport} Viewport.
     95  */
     96 SlideMode.prototype.getViewport = function() { return this.viewport_; };
     97 
     98 /**
     99  * Initialize the listeners.
    100  * @private
    101  */
    102 SlideMode.prototype.initListeners_ = function() {
    103   window.addEventListener('resize', this.onResize_.bind(this));
    104 };
    105 
    106 /**
    107  * Initialize the UI.
    108  * @private
    109  */
    110 SlideMode.prototype.initDom_ = function() {
    111   // Container for displayed image.
    112   this.imageContainer_ = util.createChild(
    113       this.document_.querySelector('.content'), 'image-container');
    114   this.imageContainer_.addEventListener('click', this.onClick_.bind(this));
    115 
    116   this.document_.addEventListener('click', this.onDocumentClick_.bind(this));
    117 
    118   // Overwrite options and info bubble.
    119   this.options_ = util.createChild(
    120       this.toolbar_.querySelector('.filename-spacer'), 'options');
    121 
    122   this.savedLabel_ = util.createChild(this.options_, 'saved');
    123   this.savedLabel_.textContent = this.displayStringFunction_('GALLERY_SAVED');
    124 
    125   var overwriteOriginalBox =
    126       util.createChild(this.options_, 'overwrite-original');
    127 
    128   this.overwriteOriginal_ = util.createChild(
    129       overwriteOriginalBox, '', 'input');
    130   this.overwriteOriginal_.type = 'checkbox';
    131   this.overwriteOriginal_.id = 'overwrite-checkbox';
    132   chrome.storage.local.get(SlideMode.OVERWRITE_KEY, function(values) {
    133     var value = values[SlideMode.OVERWRITE_KEY];
    134     // Out-of-the box default is 'true'
    135     this.overwriteOriginal_.checked =
    136         (value === 'false' || value === false) ? false : true;
    137   }.bind(this));
    138   this.overwriteOriginal_.addEventListener('click',
    139       this.onOverwriteOriginalClick_.bind(this));
    140 
    141   var overwriteLabel = util.createChild(overwriteOriginalBox, '', 'label');
    142   overwriteLabel.textContent =
    143       this.displayStringFunction_('GALLERY_OVERWRITE_ORIGINAL');
    144   overwriteLabel.setAttribute('for', 'overwrite-checkbox');
    145 
    146   this.bubble_ = util.createChild(this.toolbar_, 'bubble');
    147   this.bubble_.hidden = true;
    148 
    149   var bubbleContent = util.createChild(this.bubble_);
    150   bubbleContent.innerHTML = this.displayStringFunction_(
    151       'GALLERY_OVERWRITE_BUBBLE');
    152 
    153   util.createChild(this.bubble_, 'pointer bottom', 'span');
    154 
    155   var bubbleClose = util.createChild(this.bubble_, 'close-x');
    156   bubbleClose.addEventListener('click', this.onCloseBubble_.bind(this));
    157 
    158   // Ribbon and related controls.
    159   this.arrowBox_ = util.createChild(this.container_, 'arrow-box');
    160 
    161   this.arrowLeft_ =
    162       util.createChild(this.arrowBox_, 'arrow left tool dimmable');
    163   this.arrowLeft_.addEventListener('click',
    164       this.advanceManually.bind(this, -1));
    165   util.createChild(this.arrowLeft_);
    166 
    167   util.createChild(this.arrowBox_, 'arrow-spacer');
    168 
    169   this.arrowRight_ =
    170       util.createChild(this.arrowBox_, 'arrow right tool dimmable');
    171   this.arrowRight_.addEventListener('click',
    172       this.advanceManually.bind(this, 1));
    173   util.createChild(this.arrowRight_);
    174 
    175   this.ribbonSpacer_ = this.toolbar_.querySelector('.ribbon-spacer');
    176   this.ribbon_ = new Ribbon(
    177       this.document_, this.dataModel_, this.selectionModel_);
    178   this.ribbonSpacer_.appendChild(this.ribbon_);
    179 
    180   // Error indicator.
    181   var errorWrapper = util.createChild(this.container_, 'prompt-wrapper');
    182   errorWrapper.setAttribute('pos', 'center');
    183 
    184   this.errorBanner_ = util.createChild(errorWrapper, 'error-banner');
    185 
    186   util.createChild(this.container_, 'spinner');
    187 
    188   var slideShowButton = this.toolbar_.querySelector('button.slideshow');
    189   slideShowButton.title = this.displayStringFunction_('GALLERY_SLIDESHOW');
    190   slideShowButton.addEventListener('click',
    191       this.startSlideshow.bind(this, SlideMode.SLIDESHOW_INTERVAL_FIRST));
    192 
    193   var slideShowToolbar =
    194       util.createChild(this.container_, 'tool slideshow-toolbar');
    195   util.createChild(slideShowToolbar, 'slideshow-play').
    196       addEventListener('click', this.toggleSlideshowPause_.bind(this));
    197   util.createChild(slideShowToolbar, 'slideshow-end').
    198       addEventListener('click', this.stopSlideshow_.bind(this));
    199 
    200   // Editor.
    201 
    202   this.editButton_ = this.toolbar_.querySelector('button.edit');
    203   this.editButton_.title = this.displayStringFunction_('GALLERY_EDIT');
    204   this.editButton_.setAttribute('disabled', '');  // Disabled by default.
    205   this.editButton_.addEventListener('click', this.toggleEditor.bind(this));
    206 
    207   this.printButton_ = this.toolbar_.querySelector('button.print');
    208   this.printButton_.title = this.displayStringFunction_('GALLERY_PRINT');
    209   this.printButton_.setAttribute('disabled', '');  // Disabled by default.
    210   this.printButton_.addEventListener('click', this.print_.bind(this));
    211 
    212   this.editBarSpacer_ = this.toolbar_.querySelector('.edit-bar-spacer');
    213   this.editBarMain_ = util.createChild(this.editBarSpacer_, 'edit-main');
    214 
    215   this.editBarMode_ = util.createChild(this.container_, 'edit-modal');
    216   this.editBarModeWrapper_ = util.createChild(
    217       this.editBarMode_, 'edit-modal-wrapper dimmable');
    218   this.editBarModeWrapper_.hidden = true;
    219 
    220   // Objects supporting image display and editing.
    221   this.viewport_ = new Viewport();
    222 
    223   this.imageView_ = new ImageView(
    224       this.imageContainer_,
    225       this.viewport_);
    226 
    227   this.editor_ = new ImageEditor(
    228       this.viewport_,
    229       this.imageView_,
    230       this.prompt_,
    231       {
    232         root: this.container_,
    233         image: this.imageContainer_,
    234         toolbar: this.editBarMain_,
    235         mode: this.editBarModeWrapper_
    236       },
    237       SlideMode.EDITOR_MODES,
    238       this.displayStringFunction_,
    239       this.onToolsVisibilityChanged_.bind(this));
    240 
    241   this.touchHandlers_ = new TouchHandler(this.imageContainer_, this);
    242 };
    243 
    244 /**
    245  * Load items, display the selected item.
    246  * @param {Rect} zoomFromRect Rectangle for zoom effect.
    247  * @param {function} displayCallback Called when the image is displayed.
    248  * @param {function} loadCallback Called when the image is displayed.
    249  */
    250 SlideMode.prototype.enter = function(
    251     zoomFromRect, displayCallback, loadCallback) {
    252   this.sequenceDirection_ = 0;
    253   this.sequenceLength_ = 0;
    254 
    255   var loadDone = function(loadType, delay) {
    256     this.active_ = true;
    257 
    258     this.selectionModel_.addEventListener('change', this.onSelectionBound_);
    259     this.dataModel_.addEventListener('splice', this.onSpliceBound_);
    260 
    261     ImageUtil.setAttribute(this.arrowBox_, 'active', this.getItemCount_() > 1);
    262     this.ribbon_.enable();
    263 
    264     // Wait 1000ms after the animation is done, then prefetch the next image.
    265     this.requestPrefetch(1, delay + 1000);
    266 
    267     if (loadCallback) loadCallback();
    268   }.bind(this);
    269 
    270   // The latest |leave| call might have left the image animating. Remove it.
    271   this.unloadImage_();
    272 
    273   new Promise(function(fulfill) {
    274     // If the items are empty, just show the error message.
    275     if (this.getItemCount_() === 0) {
    276       this.displayedIndex_ = -1;
    277       //TODO(hirono) Show this message in the grid mode too.
    278       this.showErrorBanner_('GALLERY_NO_IMAGES');
    279       fulfill();
    280       return;
    281     }
    282 
    283     // Remember the selection if it is empty or multiple. It will be restored
    284     // in |leave| if the user did not changing the selection manually.
    285     var currentSelection = this.selectionModel_.selectedIndexes;
    286     if (currentSelection.length === 1)
    287       this.savedSelection_ = null;
    288     else
    289       this.savedSelection_ = currentSelection;
    290 
    291     // Ensure valid single selection.
    292     // Note that the SlideMode object is not listening to selection change yet.
    293     this.select(Math.max(0, this.getSelectedIndex()));
    294     this.displayedIndex_ = this.getSelectedIndex();
    295 
    296     // Show the selected item ASAP, then complete the initialization
    297     // (loading the ribbon thumbnails can take some time).
    298     var selectedItem = this.getSelectedItem();
    299 
    300     // Load the image of the item.
    301     this.loadItem_(
    302         selectedItem,
    303         zoomFromRect && this.imageView_.createZoomEffect(zoomFromRect),
    304         displayCallback,
    305         function(loadType, delay) {
    306           fulfill(delay);
    307         });
    308   }.bind(this)).then(function(delay) {
    309     // Turn the mode active.
    310     this.active_ = true;
    311     ImageUtil.setAttribute(this.arrowBox_, 'active', this.getItemCount_() > 1);
    312     this.ribbon_.enable();
    313 
    314     // Register handlers.
    315     this.selectionModel_.addEventListener('change', this.onSelectionBound_);
    316     this.dataModel_.addEventListener('splice', this.onSpliceBound_);
    317     this.touchHandlers_.enabled = true;
    318 
    319     // Wait 1000ms after the animation is done, then prefetch the next image.
    320     this.requestPrefetch(1, delay + 1000);
    321 
    322     // Call load callback.
    323     if (loadCallback)
    324       loadCallback();
    325   }.bind(this)).catch(function(error) {
    326     console.error(error.stack, error);
    327   });
    328 };
    329 
    330 /**
    331  * Leave the mode.
    332  * @param {Rect} zoomToRect Rectangle for zoom effect.
    333  * @param {function} callback Called when the image is committed and
    334  *   the zoom-out animation has started.
    335  */
    336 SlideMode.prototype.leave = function(zoomToRect, callback) {
    337   var commitDone = function() {
    338     this.stopEditing_();
    339     this.stopSlideshow_();
    340     ImageUtil.setAttribute(this.arrowBox_, 'active', false);
    341     this.selectionModel_.removeEventListener(
    342         'change', this.onSelectionBound_);
    343     this.dataModel_.removeEventListener('splice', this.onSpliceBound_);
    344     this.ribbon_.disable();
    345     this.active_ = false;
    346     if (this.savedSelection_)
    347       this.selectionModel_.selectedIndexes = this.savedSelection_;
    348     this.unloadImage_(zoomToRect);
    349     callback();
    350   }.bind(this);
    351 
    352   this.viewport_.resetView();
    353   if (this.getItemCount_() === 0) {
    354     this.showErrorBanner_(false);
    355     commitDone();
    356   } else {
    357     this.commitItem_(commitDone);
    358   }
    359 
    360   // Disable the slide-mode only buttons when leaving.
    361   this.editButton_.setAttribute('disabled', '');
    362   this.printButton_.setAttribute('disabled', '');
    363 
    364   // Disable touch operation.
    365   this.touchHandlers_.enabled = false;
    366 };
    367 
    368 
    369 /**
    370  * Execute an action when the editor is not busy.
    371  *
    372  * @param {function} action Function to execute.
    373  */
    374 SlideMode.prototype.executeWhenReady = function(action) {
    375   this.editor_.executeWhenReady(action);
    376 };
    377 
    378 /**
    379  * @return {boolean} True if the mode has active tools (that should not fade).
    380  */
    381 SlideMode.prototype.hasActiveTool = function() {
    382   return this.isEditing();
    383 };
    384 
    385 /**
    386  * @return {number} Item count.
    387  * @private
    388  */
    389 SlideMode.prototype.getItemCount_ = function() {
    390   return this.dataModel_.length;
    391 };
    392 
    393 /**
    394  * @param {number} index Index.
    395  * @return {Gallery.Item} Item.
    396  */
    397 SlideMode.prototype.getItem = function(index) {
    398   return this.dataModel_.item(index);
    399 };
    400 
    401 /**
    402  * @return {Gallery.Item} Selected index.
    403  */
    404 SlideMode.prototype.getSelectedIndex = function() {
    405   return this.selectionModel_.selectedIndex;
    406 };
    407 
    408 /**
    409  * @return {Rect} Screen rectangle of the selected image.
    410  */
    411 SlideMode.prototype.getSelectedImageRect = function() {
    412   if (this.getSelectedIndex() < 0)
    413     return null;
    414   else
    415     return this.viewport_.getImageBoundsOnScreen();
    416 };
    417 
    418 /**
    419  * @return {Gallery.Item} Selected item.
    420  */
    421 SlideMode.prototype.getSelectedItem = function() {
    422   return this.getItem(this.getSelectedIndex());
    423 };
    424 
    425 /**
    426  * Toggles the full screen mode.
    427  * @private
    428  */
    429 SlideMode.prototype.toggleFullScreen_ = function() {
    430   util.toggleFullScreen(this.context_.appWindow,
    431                         !util.isFullScreen(this.context_.appWindow));
    432 };
    433 
    434 /**
    435  * Selection change handler.
    436  *
    437  * Commits the current image and displays the newly selected image.
    438  * @private
    439  */
    440 SlideMode.prototype.onSelection_ = function() {
    441   if (this.selectionModel_.selectedIndexes.length === 0)
    442     return;  // Temporary empty selection.
    443 
    444   // Forget the saved selection if the user changed the selection manually.
    445   if (!this.isSlideshowOn_())
    446     this.savedSelection_ = null;
    447 
    448   if (this.getSelectedIndex() === this.displayedIndex_)
    449     return;  // Do not reselect.
    450 
    451   this.commitItem_(this.loadSelectedItem_.bind(this));
    452 };
    453 
    454 /**
    455  * Handles changes in tools visibility, and if the header is dimmed, then
    456  * requests disabling the draggable app region.
    457  *
    458  * @private
    459  */
    460 SlideMode.prototype.onToolsVisibilityChanged_ = function() {
    461   var headerDimmed =
    462       this.document_.querySelector('.header').hasAttribute('dimmed');
    463   this.context_.onAppRegionChanged(!headerDimmed);
    464 };
    465 
    466 /**
    467  * Change the selection.
    468  *
    469  * @param {number} index New selected index.
    470  * @param {number=} opt_slideHint Slide animation direction (-1|1).
    471  */
    472 SlideMode.prototype.select = function(index, opt_slideHint) {
    473   this.slideHint_ = opt_slideHint;
    474   this.selectionModel_.selectedIndex = index;
    475   this.selectionModel_.leadIndex = index;
    476 };
    477 
    478 /**
    479  * Load the selected item.
    480  *
    481  * @private
    482  */
    483 SlideMode.prototype.loadSelectedItem_ = function() {
    484   var slideHint = this.slideHint_;
    485   this.slideHint_ = undefined;
    486 
    487   var index = this.getSelectedIndex();
    488   if (index === this.displayedIndex_)
    489     return;  // Do not reselect.
    490 
    491   var step = slideHint || (index - this.displayedIndex_);
    492 
    493   if (Math.abs(step) != 1) {
    494     // Long leap, the sequence is broken, we have no good prefetch candidate.
    495     this.sequenceDirection_ = 0;
    496     this.sequenceLength_ = 0;
    497   } else if (this.sequenceDirection_ === step) {
    498     // Keeping going in sequence.
    499     this.sequenceLength_++;
    500   } else {
    501     // Reversed the direction. Reset the counter.
    502     this.sequenceDirection_ = step;
    503     this.sequenceLength_ = 1;
    504   }
    505 
    506   this.displayedIndex_ = index;
    507   var selectedItem = this.getSelectedItem();
    508 
    509   if (this.sequenceLength_ <= 1) {
    510     // We have just broke the sequence. Touch the current image so that it stays
    511     // in the cache longer.
    512     this.imageView_.prefetch(selectedItem);
    513   }
    514 
    515   function shouldPrefetch(loadType, step, sequenceLength) {
    516     // Never prefetch when selecting out of sequence.
    517     if (Math.abs(step) != 1)
    518       return false;
    519 
    520     // Always prefetch if the previous load was from cache.
    521     if (loadType === ImageView.LOAD_TYPE_CACHED_FULL)
    522       return true;
    523 
    524     // Prefetch if we have been going in the same direction for long enough.
    525     return sequenceLength >= 3;
    526   }
    527 
    528   this.currentUniqueKey_++;
    529   var selectedUniqueKey = this.currentUniqueKey_;
    530 
    531   // Discard, since another load has been invoked after this one.
    532   if (selectedUniqueKey != this.currentUniqueKey_)
    533     return;
    534 
    535   this.loadItem_(
    536       selectedItem,
    537       new ImageView.Effect.Slide(step, this.isSlideshowPlaying_()),
    538       function() {} /* no displayCallback */,
    539       function(loadType, delay) {
    540         // Discard, since another load has been invoked after this one.
    541         if (selectedUniqueKey != this.currentUniqueKey_)
    542           return;
    543         if (shouldPrefetch(loadType, step, this.sequenceLength_))
    544           this.requestPrefetch(step, delay);
    545         if (this.isSlideshowPlaying_())
    546           this.scheduleNextSlide_();
    547       }.bind(this));
    548 };
    549 
    550 /**
    551  * Unload the current image.
    552  *
    553  * @param {Rect} zoomToRect Rectangle for zoom effect.
    554  * @private
    555  */
    556 SlideMode.prototype.unloadImage_ = function(zoomToRect) {
    557   this.imageView_.unload(zoomToRect);
    558 };
    559 
    560 /**
    561  * Data model 'splice' event handler.
    562  * @param {Event} event Event.
    563  * @private
    564  */
    565 SlideMode.prototype.onSplice_ = function(event) {
    566   ImageUtil.setAttribute(this.arrowBox_, 'active', this.getItemCount_() > 1);
    567 
    568   // Splice invalidates saved indices, drop the saved selection.
    569   this.savedSelection_ = null;
    570 
    571   if (event.removed.length != 1)
    572     return;
    573 
    574   // Delay the selection to let the ribbon splice handler work first.
    575   setTimeout(function() {
    576     if (event.index < this.dataModel_.length) {
    577       // There is the next item, select it.
    578       // The next item is now at the same index as the removed one, so we need
    579       // to correct displayIndex_ so that loadSelectedItem_ does not think
    580       // we are re-selecting the same item (and does right-to-left slide-in
    581       // animation).
    582       this.displayedIndex_ = event.index - 1;
    583       this.select(event.index);
    584     } else if (this.dataModel_.length) {
    585       // Removed item is the rightmost, but there are more items.
    586       this.select(event.index - 1);  // Select the new last index.
    587     } else {
    588       // No items left. Unload the image and show the banner.
    589       this.commitItem_(function() {
    590         this.unloadImage_();
    591         this.showErrorBanner_('GALLERY_NO_IMAGES');
    592       }.bind(this));
    593     }
    594   }.bind(this), 0);
    595 };
    596 
    597 /**
    598  * @param {number} direction -1 for left, 1 for right.
    599  * @return {number} Next index in the given direction, with wrapping.
    600  * @private
    601  */
    602 SlideMode.prototype.getNextSelectedIndex_ = function(direction) {
    603   function advance(index, limit) {
    604     index += (direction > 0 ? 1 : -1);
    605     if (index < 0)
    606       return limit - 1;
    607     if (index === limit)
    608       return 0;
    609     return index;
    610   }
    611 
    612   // If the saved selection is multiple the Slideshow should cycle through
    613   // the saved selection.
    614   if (this.isSlideshowOn_() &&
    615       this.savedSelection_ && this.savedSelection_.length > 1) {
    616     var pos = advance(this.savedSelection_.indexOf(this.getSelectedIndex()),
    617         this.savedSelection_.length);
    618     return this.savedSelection_[pos];
    619   } else {
    620     return advance(this.getSelectedIndex(), this.getItemCount_());
    621   }
    622 };
    623 
    624 /**
    625  * Advance the selection based on the pressed key ID.
    626  * @param {string} keyID Key identifier.
    627  */
    628 SlideMode.prototype.advanceWithKeyboard = function(keyID) {
    629   var prev = (keyID === 'Up' ||
    630               keyID === 'Left' ||
    631               keyID === 'MediaPreviousTrack');
    632   this.advanceManually(prev ? -1 : 1);
    633 };
    634 
    635 /**
    636  * Advance the selection as a result of a user action (as opposed to an
    637  * automatic change in the slideshow mode).
    638  * @param {number} direction -1 for left, 1 for right.
    639  */
    640 SlideMode.prototype.advanceManually = function(direction) {
    641   if (this.isSlideshowPlaying_())
    642     this.pauseSlideshow_();
    643   cr.dispatchSimpleEvent(this, 'useraction');
    644   this.selectNext(direction);
    645 };
    646 
    647 /**
    648  * Select the next item.
    649  * @param {number} direction -1 for left, 1 for right.
    650  */
    651 SlideMode.prototype.selectNext = function(direction) {
    652   this.select(this.getNextSelectedIndex_(direction), direction);
    653 };
    654 
    655 /**
    656  * Select the first item.
    657  */
    658 SlideMode.prototype.selectFirst = function() {
    659   this.select(0);
    660 };
    661 
    662 /**
    663  * Select the last item.
    664  */
    665 SlideMode.prototype.selectLast = function() {
    666   this.select(this.getItemCount_() - 1);
    667 };
    668 
    669 // Loading/unloading
    670 
    671 /**
    672  * Load and display an item.
    673  *
    674  * @param {Gallery.Item} item Item.
    675  * @param {Object} effect Transition effect object.
    676  * @param {function} displayCallback Called when the image is displayed
    677  *     (which can happen before the image load due to caching).
    678  * @param {function} loadCallback Called when the image is fully loaded.
    679  * @private
    680  */
    681 SlideMode.prototype.loadItem_ = function(
    682     item, effect, displayCallback, loadCallback) {
    683   var entry = item.getEntry();
    684   var metadata = item.getMetadata();
    685   this.showSpinner_(true);
    686 
    687   var loadDone = function(loadType, delay, error) {
    688     this.showSpinner_(false);
    689     if (loadType === ImageView.LOAD_TYPE_ERROR) {
    690       // if we have a specific error, then display it
    691       if (error) {
    692         this.showErrorBanner_(error);
    693       } else {
    694         // otherwise try to infer general error
    695         this.showErrorBanner_('GALLERY_IMAGE_ERROR');
    696       }
    697     } else if (loadType === ImageView.LOAD_TYPE_OFFLINE) {
    698       this.showErrorBanner_('GALLERY_IMAGE_OFFLINE');
    699     }
    700 
    701     ImageUtil.metrics.recordUserAction(ImageUtil.getMetricName('View'));
    702 
    703     var toMillions = function(number) {
    704       return Math.round(number / (1000 * 1000));
    705     };
    706 
    707     ImageUtil.metrics.recordSmallCount(ImageUtil.getMetricName('Size.MB'),
    708         toMillions(metadata.filesystem.size));
    709 
    710     var canvas = this.imageView_.getCanvas();
    711     ImageUtil.metrics.recordSmallCount(ImageUtil.getMetricName('Size.MPix'),
    712         toMillions(canvas.width * canvas.height));
    713 
    714     var extIndex = entry.name.lastIndexOf('.');
    715     var ext = extIndex < 0 ? '' :
    716         entry.name.substr(extIndex + 1).toLowerCase();
    717     if (ext === 'jpeg') ext = 'jpg';
    718     ImageUtil.metrics.recordEnum(
    719         ImageUtil.getMetricName('FileType'), ext, ImageUtil.FILE_TYPES);
    720 
    721     // Enable or disable buttons for editing and printing.
    722     if (error) {
    723       this.editButton_.setAttribute('disabled', '');
    724       this.printButton_.setAttribute('disabled', '');
    725     } else {
    726       this.editButton_.removeAttribute('disabled');
    727       this.printButton_.removeAttribute('disabled');
    728     }
    729 
    730     // For once edited image, disallow the 'overwrite' setting change.
    731     ImageUtil.setAttribute(this.options_, 'saved',
    732         !this.getSelectedItem().isOriginal());
    733 
    734     chrome.storage.local.get(SlideMode.OVERWRITE_BUBBLE_KEY,
    735         function(values) {
    736           var times = values[SlideMode.OVERWRITE_BUBBLE_KEY] || 0;
    737           if (times < SlideMode.OVERWRITE_BUBBLE_MAX_TIMES) {
    738             this.bubble_.hidden = false;
    739             if (this.isEditing()) {
    740               var items = {};
    741               items[SlideMode.OVERWRITE_BUBBLE_KEY] = times + 1;
    742               chrome.storage.local.set(items);
    743             }
    744           }
    745         }.bind(this));
    746 
    747     loadCallback(loadType, delay);
    748   }.bind(this);
    749 
    750   var displayDone = function() {
    751     cr.dispatchSimpleEvent(this, 'image-displayed');
    752     displayCallback();
    753   }.bind(this);
    754 
    755   this.editor_.openSession(
    756       item,
    757       effect,
    758       this.saveCurrentImage_.bind(this, item),
    759       displayDone,
    760       loadDone);
    761 };
    762 
    763 /**
    764  * Commit changes to the current item and reset all messages/indicators.
    765  *
    766  * @param {function} callback Callback.
    767  * @private
    768  */
    769 SlideMode.prototype.commitItem_ = function(callback) {
    770   this.showSpinner_(false);
    771   this.showErrorBanner_(false);
    772   this.editor_.getPrompt().hide();
    773   this.editor_.closeSession(callback);
    774 };
    775 
    776 /**
    777  * Request a prefetch for the next image.
    778  *
    779  * @param {number} direction -1 or 1.
    780  * @param {number} delay Delay in ms. Used to prevent the CPU-heavy image
    781  *   loading from disrupting the animation that might be still in progress.
    782  */
    783 SlideMode.prototype.requestPrefetch = function(direction, delay) {
    784   if (this.getItemCount_() <= 1) return;
    785 
    786   var index = this.getNextSelectedIndex_(direction);
    787   this.imageView_.prefetch(this.getItem(index), delay);
    788 };
    789 
    790 // Event handlers.
    791 
    792 /**
    793  * Unload handler, to be called from the top frame.
    794  * @param {boolean} exiting True if the app is exiting.
    795  */
    796 SlideMode.prototype.onUnload = function(exiting) {
    797 };
    798 
    799 /**
    800  * Click handler for the image container.
    801  *
    802  * @param {Event} event Mouse click event.
    803  * @private
    804  */
    805 SlideMode.prototype.onClick_ = function(event) {
    806 };
    807 
    808 /**
    809  * Click handler for the entire document.
    810  * @param {Event} e Mouse click event.
    811  * @private
    812  */
    813 SlideMode.prototype.onDocumentClick_ = function(e) {
    814   // Close the bubble if clicked outside of it and if it is visible.
    815   if (!this.bubble_.contains(e.target) &&
    816       !this.editButton_.contains(e.target) &&
    817       !this.arrowLeft_.contains(e.target) &&
    818       !this.arrowRight_.contains(e.target) &&
    819       !this.bubble_.hidden) {
    820     this.bubble_.hidden = true;
    821   }
    822 };
    823 
    824 /**
    825  * Keydown handler.
    826  *
    827  * @param {Event} event Event.
    828  * @return {boolean} True if handled.
    829  */
    830 SlideMode.prototype.onKeyDown = function(event) {
    831   var keyID = util.getKeyModifiers(event) + event.keyIdentifier;
    832 
    833   if (this.isSlideshowOn_()) {
    834     switch (keyID) {
    835       case 'U+001B':  // Escape exits the slideshow.
    836       case 'MediaStop':
    837         this.stopSlideshow_(event);
    838         break;
    839 
    840       case 'U+0020':  // Space pauses/resumes the slideshow.
    841       case 'MediaPlayPause':
    842         this.toggleSlideshowPause_();
    843         break;
    844 
    845       case 'Up':
    846       case 'Down':
    847       case 'Left':
    848       case 'Right':
    849       case 'MediaNextTrack':
    850       case 'MediaPreviousTrack':
    851         this.advanceWithKeyboard(keyID);
    852         break;
    853     }
    854     return true;  // Consume all keystrokes in the slideshow mode.
    855   }
    856 
    857   if (this.isEditing() && this.editor_.onKeyDown(event))
    858     return true;
    859 
    860   switch (keyID) {
    861     case 'Ctrl-U+0050':  // Ctrl+'p' prints the current image.
    862       if (!this.printButton_.hasAttribute('disabled'))
    863         this.print_();
    864       break;
    865 
    866     case 'U+0045':  // 'e' toggles the editor.
    867       if (!this.editButton_.hasAttribute('disabled'))
    868         this.toggleEditor(event);
    869       break;
    870 
    871     case 'U+001B':  // Escape
    872       if (this.isEditing()) {
    873         this.toggleEditor(event);
    874       } else if (this.viewport_.isZoomed()) {
    875         this.viewport_.resetView();
    876         this.touchHandlers_.stopOperation();
    877         this.imageView_.applyViewportChange();
    878       } else {
    879         return false;  // Not handled.
    880       }
    881       break;
    882 
    883     case 'Home':
    884       this.selectFirst();
    885       break;
    886     case 'End':
    887       this.selectLast();
    888       break;
    889     case 'Up':
    890     case 'Down':
    891     case 'Left':
    892     case 'Right':
    893       if (!this.isEditing() && this.viewport_.isZoomed()) {
    894         var delta = SlideMode.KEY_OFFSET_MAP[keyID];
    895         this.viewport_.setOffset(
    896             ~~(this.viewport_.getOffsetX() +
    897                delta[0] * this.viewport_.getZoom()),
    898             ~~(this.viewport_.getOffsetY() +
    899                delta[1] * this.viewport_.getZoom()));
    900         this.touchHandlers_.stopOperation();
    901         this.imageView_.applyViewportChange();
    902       } else {
    903         this.advanceWithKeyboard(keyID);
    904       }
    905       break;
    906     case 'MediaNextTrack':
    907     case 'MediaPreviousTrack':
    908       this.advanceWithKeyboard(keyID);
    909       break;
    910 
    911     case 'Ctrl-U+00BB':  // Ctrl+'=' zoom in.
    912       if (!this.isEditing()) {
    913         this.viewport_.zoomIn();
    914         this.touchHandlers_.stopOperation();
    915         this.imageView_.applyViewportChange();
    916       }
    917       break;
    918 
    919     case 'Ctrl-U+00BD':  // Ctrl+'-' zoom out.
    920       if (!this.isEditing()) {
    921         this.viewport_.zoomOut();
    922         this.touchHandlers_.stopOperation();
    923         this.imageView_.applyViewportChange();
    924       }
    925       break;
    926 
    927     case 'Ctrl-U+0030': // Ctrl+'0' zoom reset.
    928       if (!this.isEditing()) {
    929         this.viewport_.setZoom(1.0);
    930         this.touchHandlers_.stopOperation();
    931         this.imageView_.applyViewportChange();
    932       }
    933       break;
    934   }
    935 
    936   return true;
    937 };
    938 
    939 /**
    940  * Resize handler.
    941  * @private
    942  */
    943 SlideMode.prototype.onResize_ = function() {
    944   this.viewport_.setScreenSize(
    945       this.container_.clientWidth, this.container_.clientHeight);
    946   this.touchHandlers_.stopOperation();
    947   this.editor_.getBuffer().draw();
    948 };
    949 
    950 /**
    951  * Update thumbnails.
    952  */
    953 SlideMode.prototype.updateThumbnails = function() {
    954   this.ribbon_.reset();
    955   if (this.active_)
    956     this.ribbon_.redraw();
    957 };
    958 
    959 // Saving
    960 
    961 /**
    962  * Save the current image to a file.
    963  *
    964  * @param {Gallery.Item} item Item to save the image.
    965  * @param {function} callback Callback.
    966  * @private
    967  */
    968 SlideMode.prototype.saveCurrentImage_ = function(item, callback) {
    969   this.showSpinner_(true);
    970 
    971   var savedPromise = this.dataModel_.saveItem(
    972       this.volumeManager_,
    973       item,
    974       this.imageView_.getCanvas(),
    975       this.shouldOverwriteOriginal_());
    976 
    977   savedPromise.catch(function(error) {
    978     // TODO(hirono): Implement write error handling.
    979     // Until then pretend that the save succeeded.
    980     console.error(error.stack || error);
    981   }).then(function() {
    982     this.showSpinner_(false);
    983     this.flashSavedLabel_();
    984 
    985     // Allow changing the 'Overwrite original' setting only if the user
    986     // used Undo to restore the original image AND it is not a copy.
    987     // Otherwise lock the setting in its current state.
    988     var mayChangeOverwrite = !this.editor_.canUndo() && item.isOriginal();
    989     ImageUtil.setAttribute(this.options_, 'saved', !mayChangeOverwrite);
    990 
    991     // Record UMA for the first edit.
    992     if (this.imageView_.getContentRevision() === 1)
    993       ImageUtil.metrics.recordUserAction(ImageUtil.getMetricName('Edit'));
    994 
    995     callback();
    996     cr.dispatchSimpleEvent(this, 'image-saved');
    997   }.bind(this)).catch(function(error) {
    998     console.error(error.stack || error);
    999   });
   1000 };
   1001 
   1002 /**
   1003  * Flash 'Saved' label briefly to indicate that the image has been saved.
   1004  * @private
   1005  */
   1006 SlideMode.prototype.flashSavedLabel_ = function() {
   1007   var setLabelHighlighted =
   1008       ImageUtil.setAttribute.bind(null, this.savedLabel_, 'highlighted');
   1009   setTimeout(setLabelHighlighted.bind(null, true), 0);
   1010   setTimeout(setLabelHighlighted.bind(null, false), 300);
   1011 };
   1012 
   1013 /**
   1014  * Local storage key for the 'Overwrite original' setting.
   1015  * @type {string}
   1016  */
   1017 SlideMode.OVERWRITE_KEY = 'gallery-overwrite-original';
   1018 
   1019 /**
   1020  * Local storage key for the number of times that
   1021  * the overwrite info bubble has been displayed.
   1022  * @type {string}
   1023  */
   1024 SlideMode.OVERWRITE_BUBBLE_KEY = 'gallery-overwrite-bubble';
   1025 
   1026 /**
   1027  * Max number that the overwrite info bubble is shown.
   1028  * @type {number}
   1029  */
   1030 SlideMode.OVERWRITE_BUBBLE_MAX_TIMES = 5;
   1031 
   1032 /**
   1033  * @return {boolean} True if 'Overwrite original' is set.
   1034  * @private
   1035  */
   1036 SlideMode.prototype.shouldOverwriteOriginal_ = function() {
   1037   return this.overwriteOriginal_.checked;
   1038 };
   1039 
   1040 /**
   1041  * 'Overwrite original' checkbox handler.
   1042  * @param {Event} event Event.
   1043  * @private
   1044  */
   1045 SlideMode.prototype.onOverwriteOriginalClick_ = function(event) {
   1046   var items = {};
   1047   items[SlideMode.OVERWRITE_KEY] = event.target.checked;
   1048   chrome.storage.local.set(items);
   1049 };
   1050 
   1051 /**
   1052  * Overwrite info bubble close handler.
   1053  * @private
   1054  */
   1055 SlideMode.prototype.onCloseBubble_ = function() {
   1056   this.bubble_.hidden = true;
   1057   var items = {};
   1058   items[SlideMode.OVERWRITE_BUBBLE_KEY] =
   1059       SlideMode.OVERWRITE_BUBBLE_MAX_TIMES;
   1060   chrome.storage.local.set(items);
   1061 };
   1062 
   1063 // Slideshow
   1064 
   1065 /**
   1066  * Slideshow interval in ms.
   1067  */
   1068 SlideMode.SLIDESHOW_INTERVAL = 5000;
   1069 
   1070 /**
   1071  * First slideshow interval in ms. It should be shorter so that the user
   1072  * is not guessing whether the button worked.
   1073  */
   1074 SlideMode.SLIDESHOW_INTERVAL_FIRST = 1000;
   1075 
   1076 /**
   1077  * Empirically determined duration of the fullscreen toggle animation.
   1078  */
   1079 SlideMode.FULLSCREEN_TOGGLE_DELAY = 500;
   1080 
   1081 /**
   1082  * @return {boolean} True if the slideshow is on.
   1083  * @private
   1084  */
   1085 SlideMode.prototype.isSlideshowOn_ = function() {
   1086   return this.container_.hasAttribute('slideshow');
   1087 };
   1088 
   1089 /**
   1090  * Starts the slideshow.
   1091  * @param {number=} opt_interval First interval in ms.
   1092  * @param {Event=} opt_event Event.
   1093  */
   1094 SlideMode.prototype.startSlideshow = function(opt_interval, opt_event) {
   1095   // Reset zoom.
   1096   this.viewport_.resetView();
   1097   this.imageView_.applyViewportChange();
   1098 
   1099   // Disable touch operation.
   1100   this.touchHandlers_.enabled = false;
   1101 
   1102   // Set the attribute early to prevent the toolbar from flashing when
   1103   // the slideshow is being started from the mosaic view.
   1104   this.container_.setAttribute('slideshow', 'playing');
   1105 
   1106   if (this.active_) {
   1107     this.stopEditing_();
   1108   } else {
   1109     // We are in the Mosaic mode. Toggle the mode but remember to return.
   1110     this.leaveAfterSlideshow_ = true;
   1111     this.toggleMode_(this.startSlideshow.bind(
   1112         this, SlideMode.SLIDESHOW_INTERVAL, opt_event));
   1113     return;
   1114   }
   1115 
   1116   if (opt_event)  // Caused by user action, notify the Gallery.
   1117     cr.dispatchSimpleEvent(this, 'useraction');
   1118 
   1119   this.fullscreenBeforeSlideshow_ = util.isFullScreen(this.context_.appWindow);
   1120   if (!this.fullscreenBeforeSlideshow_) {
   1121     // Wait until the zoom animation from the mosaic mode is done.
   1122     setTimeout(this.toggleFullScreen_.bind(this),
   1123                ImageView.ZOOM_ANIMATION_DURATION);
   1124     opt_interval = (opt_interval || SlideMode.SLIDESHOW_INTERVAL) +
   1125         SlideMode.FULLSCREEN_TOGGLE_DELAY;
   1126   }
   1127 
   1128   this.resumeSlideshow_(opt_interval);
   1129 };
   1130 
   1131 /**
   1132  * Stops the slideshow.
   1133  * @param {Event=} opt_event Event.
   1134  * @private
   1135  */
   1136 SlideMode.prototype.stopSlideshow_ = function(opt_event) {
   1137   if (!this.isSlideshowOn_())
   1138     return;
   1139 
   1140   if (opt_event)  // Caused by user action, notify the Gallery.
   1141     cr.dispatchSimpleEvent(this, 'useraction');
   1142 
   1143   this.pauseSlideshow_();
   1144   this.container_.removeAttribute('slideshow');
   1145 
   1146   // Do not restore fullscreen if we exited fullscreen while in slideshow.
   1147   var fullscreen = util.isFullScreen(this.context_.appWindow);
   1148   var toggleModeDelay = 0;
   1149   if (!this.fullscreenBeforeSlideshow_ && fullscreen) {
   1150     this.toggleFullScreen_();
   1151     toggleModeDelay = SlideMode.FULLSCREEN_TOGGLE_DELAY;
   1152   }
   1153   if (this.leaveAfterSlideshow_) {
   1154     this.leaveAfterSlideshow_ = false;
   1155     setTimeout(this.toggleMode_.bind(this), toggleModeDelay);
   1156   }
   1157 
   1158   // Re-enable touch operation.
   1159   this.touchHandlers_.enabled = true;
   1160 };
   1161 
   1162 /**
   1163  * @return {boolean} True if the slideshow is playing (not paused).
   1164  * @private
   1165  */
   1166 SlideMode.prototype.isSlideshowPlaying_ = function() {
   1167   return this.container_.getAttribute('slideshow') === 'playing';
   1168 };
   1169 
   1170 /**
   1171  * Pauses/resumes the slideshow.
   1172  * @private
   1173  */
   1174 SlideMode.prototype.toggleSlideshowPause_ = function() {
   1175   cr.dispatchSimpleEvent(this, 'useraction');  // Show the tools.
   1176   if (this.isSlideshowPlaying_()) {
   1177     this.pauseSlideshow_();
   1178   } else {
   1179     this.resumeSlideshow_(SlideMode.SLIDESHOW_INTERVAL_FIRST);
   1180   }
   1181 };
   1182 
   1183 /**
   1184  * @param {number=} opt_interval Slideshow interval in ms.
   1185  * @private
   1186  */
   1187 SlideMode.prototype.scheduleNextSlide_ = function(opt_interval) {
   1188   console.assert(this.isSlideshowPlaying_(), 'Inconsistent slideshow state');
   1189 
   1190   if (this.slideShowTimeout_)
   1191     clearTimeout(this.slideShowTimeout_);
   1192 
   1193   this.slideShowTimeout_ = setTimeout(function() {
   1194     this.slideShowTimeout_ = null;
   1195     this.selectNext(1);
   1196   }.bind(this), opt_interval || SlideMode.SLIDESHOW_INTERVAL);
   1197 };
   1198 
   1199 /**
   1200  * Resumes the slideshow.
   1201  * @param {number=} opt_interval Slideshow interval in ms.
   1202  * @private
   1203  */
   1204 SlideMode.prototype.resumeSlideshow_ = function(opt_interval) {
   1205   this.container_.setAttribute('slideshow', 'playing');
   1206   this.scheduleNextSlide_(opt_interval);
   1207 };
   1208 
   1209 /**
   1210  * Pauses the slideshow.
   1211  * @private
   1212  */
   1213 SlideMode.prototype.pauseSlideshow_ = function() {
   1214   this.container_.setAttribute('slideshow', 'paused');
   1215   if (this.slideShowTimeout_) {
   1216     clearTimeout(this.slideShowTimeout_);
   1217     this.slideShowTimeout_ = null;
   1218   }
   1219 };
   1220 
   1221 /**
   1222  * @return {boolean} True if the editor is active.
   1223  */
   1224 SlideMode.prototype.isEditing = function() {
   1225   return this.container_.hasAttribute('editing');
   1226 };
   1227 
   1228 /**
   1229  * Stops editing.
   1230  * @private
   1231  */
   1232 SlideMode.prototype.stopEditing_ = function() {
   1233   if (this.isEditing())
   1234     this.toggleEditor();
   1235 };
   1236 
   1237 /**
   1238  * Activate/deactivate editor.
   1239  * @param {Event=} opt_event Event.
   1240  */
   1241 SlideMode.prototype.toggleEditor = function(opt_event) {
   1242   if (opt_event)  // Caused by user action, notify the Gallery.
   1243     cr.dispatchSimpleEvent(this, 'useraction');
   1244 
   1245   if (!this.active_) {
   1246     this.toggleMode_(this.toggleEditor.bind(this));
   1247     return;
   1248   }
   1249 
   1250   this.stopSlideshow_();
   1251 
   1252   ImageUtil.setAttribute(this.container_, 'editing', !this.isEditing());
   1253 
   1254   if (this.isEditing()) { // isEditing has just been flipped to a new value.
   1255     // Reset zoom.
   1256     this.viewport_.resetView();
   1257     this.imageView_.applyViewportChange();
   1258     if (this.context_.readonlyDirName) {
   1259       this.editor_.getPrompt().showAt(
   1260           'top', 'GALLERY_READONLY_WARNING', 0, this.context_.readonlyDirName);
   1261     }
   1262     this.touchHandlers_.enabled = false;
   1263   } else {
   1264     this.editor_.getPrompt().hide();
   1265     this.editor_.leaveModeGently();
   1266     this.touchHandlers_.enabled = true;
   1267   }
   1268 };
   1269 
   1270 /**
   1271  * Prints the current item.
   1272  * @private
   1273  */
   1274 SlideMode.prototype.print_ = function() {
   1275   cr.dispatchSimpleEvent(this, 'useraction');
   1276   window.print();
   1277 };
   1278 
   1279 /**
   1280  * Displays the error banner.
   1281  * @param {string} message Message.
   1282  * @private
   1283  */
   1284 SlideMode.prototype.showErrorBanner_ = function(message) {
   1285   if (message) {
   1286     this.errorBanner_.textContent = this.displayStringFunction_(message);
   1287   }
   1288   ImageUtil.setAttribute(this.container_, 'error', !!message);
   1289 };
   1290 
   1291 /**
   1292  * Shows/hides the busy spinner.
   1293  *
   1294  * @param {boolean} on True if show, false if hide.
   1295  * @private
   1296  */
   1297 SlideMode.prototype.showSpinner_ = function(on) {
   1298   if (this.spinnerTimer_) {
   1299     clearTimeout(this.spinnerTimer_);
   1300     this.spinnerTimer_ = null;
   1301   }
   1302 
   1303   if (on) {
   1304     this.spinnerTimer_ = setTimeout(function() {
   1305       this.spinnerTimer_ = null;
   1306       ImageUtil.setAttribute(this.container_, 'spinner', true);
   1307     }.bind(this), 1000);
   1308   } else {
   1309     ImageUtil.setAttribute(this.container_, 'spinner', false);
   1310   }
   1311 };
   1312 
   1313 /**
   1314  * Apply the change of viewport.
   1315  */
   1316 SlideMode.prototype.applyViewportChange = function() {
   1317   this.imageView_.applyViewportChange();
   1318 };
   1319 
   1320 /**
   1321  * Touch handlers of the slide mode.
   1322  * @param {DOMElement} targetElement Event source.
   1323  * @param {SlideMode} slideMode Slide mode to be operated by the handler.
   1324  * @constructor
   1325  */
   1326 function TouchHandler(targetElement, slideMode) {
   1327   /**
   1328    * Event source.
   1329    * @type {DOMElement}
   1330    * @private
   1331    */
   1332   this.targetElement_ = targetElement;
   1333 
   1334   /**
   1335    * Target of touch operations.
   1336    * @type {SlideMode}
   1337    * @private
   1338    */
   1339   this.slideMode_ = slideMode;
   1340 
   1341   /**
   1342    * Flag to enable/disable touch operation.
   1343    * @type {boolean}
   1344    * @private
   1345    */
   1346   this.enabled_ = true;
   1347 
   1348   /**
   1349    * Whether it is in a touch operation that is started from targetElement or
   1350    * not.
   1351    * @type {boolean}
   1352    * @private
   1353    */
   1354   this.touchStarted_ = false;
   1355 
   1356   /**
   1357    * The swipe action that should happen only once in an operation is already
   1358    * done or not.
   1359    * @type {boolean}
   1360    * @private
   1361    */
   1362   this.done_ = false;
   1363 
   1364   /**
   1365    * Event on beginning of the current gesture.
   1366    * The variable is updated when the number of touch finger changed.
   1367    * @type {TouchEvent}
   1368    * @private
   1369    */
   1370   this.gestureStartEvent_ = null;
   1371 
   1372   /**
   1373    * Rotation value on beginning of the current gesture.
   1374    * @type {number}
   1375    * @private
   1376    */
   1377   this.gestureStartRotation_ = 0;
   1378 
   1379   /**
   1380    * Last touch event.
   1381    * @type {TouchEvent}
   1382    * @private
   1383    */
   1384   this.lastEvent_ = null;
   1385 
   1386   /**
   1387    * Zoom value just after last touch event.
   1388    * @type {number}
   1389    * @private
   1390    */
   1391   this.lastZoom_ = 1.0;
   1392 
   1393   targetElement.addEventListener('touchstart', this.onTouchStart_.bind(this));
   1394   var onTouchEventBound = this.onTouchEvent_.bind(this);
   1395   targetElement.ownerDocument.addEventListener('touchmove', onTouchEventBound);
   1396   targetElement.ownerDocument.addEventListener('touchend', onTouchEventBound);
   1397 
   1398   targetElement.addEventListener('mousewheel', this.onMouseWheel_.bind(this));
   1399 }
   1400 
   1401 /**
   1402  * If the user touched the image and moved the finger more than SWIPE_THRESHOLD
   1403  * horizontally it's considered as a swipe gesture (change the current image).
   1404  * @type {number}
   1405  * @const
   1406  */
   1407 TouchHandler.SWIPE_THRESHOLD = 100;
   1408 
   1409 /**
   1410  * Rotation threshold in degrees.
   1411  * @type {number}
   1412  * @const
   1413  */
   1414 TouchHandler.ROTATION_THRESHOLD = 25;
   1415 
   1416 /**
   1417  * Obtains distance between fingers.
   1418  * @param {TouchEvent} event Touch event. It should include more than two
   1419  *     touches.
   1420  * @return {boolean} Distance between touch[0] and touch[1].
   1421  */
   1422 TouchHandler.getDistance = function(event) {
   1423   var touch1 = event.touches[0];
   1424   var touch2 = event.touches[1];
   1425   var dx = touch1.clientX - touch2.clientX;
   1426   var dy = touch1.clientY - touch2.clientY;
   1427   return Math.sqrt(dx * dx + dy * dy);
   1428 };
   1429 
   1430 /**
   1431  * Obtains the degrees of the pinch twist angle.
   1432  * @param {TouchEvent} event1 Start touch event. It should include more than two
   1433  *     touches.
   1434  * @param {TouchEvent} event2 Current touch event. It should include more than
   1435  *     two touches.
   1436  * @return {number} Degrees of the pinch twist angle.
   1437  */
   1438 TouchHandler.getTwistAngle = function(event1, event2) {
   1439   var dx1 = event1.touches[1].clientX - event1.touches[0].clientX;
   1440   var dy1 = event1.touches[1].clientY - event1.touches[0].clientY;
   1441   var dx2 = event2.touches[1].clientX - event2.touches[0].clientX;
   1442   var dy2 = event2.touches[1].clientY - event2.touches[0].clientY;
   1443   var innerProduct = dx1 * dx2 + dy1 * dy2;  // |v1| * |v2| * cos(t) = x / r
   1444   var outerProduct = dx1 * dy2 - dy1 * dx2;  // |v1| * |v2| * sin(t) = y / r
   1445   return Math.atan2(outerProduct, innerProduct) * 180 / Math.PI;  // atan(y / x)
   1446 };
   1447 
   1448 TouchHandler.prototype = {
   1449   /**
   1450    * @param {boolean} flag New value.
   1451    */
   1452   set enabled(flag) {
   1453     this.enabled_ = flag;
   1454     if (!this.enabled_)
   1455       this.stopOperation();
   1456   }
   1457 };
   1458 
   1459 /**
   1460  * Stops the current touch operation.
   1461  */
   1462 TouchHandler.prototype.stopOperation = function() {
   1463   this.touchStarted_ = false;
   1464   this.done_ = false;
   1465   this.gestureStartEvent_ = null;
   1466   this.lastEvent_ = null;
   1467   this.lastZoom_ = 1.0;
   1468 };
   1469 
   1470 /**
   1471  * Handles touch start events.
   1472  * @param {TouchEvent} event Touch event.
   1473  * @private
   1474  */
   1475 TouchHandler.prototype.onTouchStart_ = function(event) {
   1476   if (this.enabled_ && event.touches.length === 1)
   1477     this.touchStarted_ = true;
   1478 };
   1479 
   1480 /**
   1481  * Handles touch move and touch end events.
   1482  * @param {TouchEvent} event Touch event.
   1483  * @private
   1484  */
   1485 TouchHandler.prototype.onTouchEvent_ = function(event) {
   1486   // Check if the current touch operation started from the target element or
   1487   // not.
   1488   if (!this.touchStarted_)
   1489     return;
   1490 
   1491   // Check if the current touch operation ends with the event.
   1492   if (event.touches.length === 0) {
   1493     this.stopOperation();
   1494     return;
   1495   }
   1496 
   1497   // Check if a new gesture started or not.
   1498   var viewport = this.slideMode_.getViewport();
   1499   if (!this.lastEvent_ ||
   1500       this.lastEvent_.touches.length !== event.touches.length) {
   1501     if (event.touches.length === 2 ||
   1502         event.touches.length === 1) {
   1503       this.gestureStartEvent_ = event;
   1504       this.gestureStartRotation_ = viewport.getRotation();
   1505       this.lastEvent_ = event;
   1506       this.lastZoom_ = viewport.getZoom();
   1507     } else {
   1508       this.gestureStartEvent_ = null;
   1509       this.gestureStartRotation_ = 0;
   1510       this.lastEvent_ = null;
   1511       this.lastZoom_ = 1.0;
   1512     }
   1513     return;
   1514   }
   1515 
   1516   // Handle the gesture movement.
   1517   switch (event.touches.length) {
   1518     case 1:
   1519       if (viewport.isZoomed()) {
   1520         // Scrolling an image by swipe.
   1521         var dx = event.touches[0].screenX - this.lastEvent_.touches[0].screenX;
   1522         var dy = event.touches[0].screenY - this.lastEvent_.touches[0].screenY;
   1523         viewport.setOffset(
   1524             viewport.getOffsetX() + dx, viewport.getOffsetY() + dy);
   1525         this.slideMode_.applyViewportChange();
   1526       } else {
   1527         // Traversing images by swipe.
   1528         if (this.done_)
   1529           break;
   1530         var dx =
   1531             event.touches[0].clientX -
   1532             this.gestureStartEvent_.touches[0].clientX;
   1533         if (dx > TouchHandler.SWIPE_THRESHOLD) {
   1534           this.slideMode_.advanceManually(-1);
   1535           this.done_ = true;
   1536         } else if (dx < -TouchHandler.SWIPE_THRESHOLD) {
   1537           this.slideMode_.advanceManually(1);
   1538           this.done_ = true;
   1539         }
   1540       }
   1541       break;
   1542 
   1543     case 2:
   1544       // Pinch zoom.
   1545       var distance1 = TouchHandler.getDistance(this.lastEvent_);
   1546       var distance2 = TouchHandler.getDistance(event);
   1547       if (distance1 === 0)
   1548         break;
   1549       var zoom = distance2 / distance1 * this.lastZoom_;
   1550       viewport.setZoom(zoom);
   1551 
   1552       // Pinch rotation.
   1553       var angle = TouchHandler.getTwistAngle(this.gestureStartEvent_, event);
   1554       if (angle > TouchHandler.ROTATION_THRESHOLD)
   1555         viewport.setRotation(this.gestureStartRotation_ + 1);
   1556       else if (angle < -TouchHandler.ROTATION_THRESHOLD)
   1557         viewport.setRotation(this.gestureStartRotation_ - 1);
   1558       else
   1559         viewport.setRotation(this.gestureStartRotation_);
   1560       this.slideMode_.applyViewportChange();
   1561       break;
   1562   }
   1563 
   1564   // Update the last event.
   1565   this.lastEvent_ = event;
   1566   this.lastZoom_ = viewport.getZoom();
   1567 };
   1568 
   1569 /**
   1570  * Handles mouse wheel events.
   1571  * @param {MouseEvent} event Wheel event.
   1572  * @private
   1573  */
   1574 TouchHandler.prototype.onMouseWheel_ = function(event) {
   1575   var viewport = this.slideMode_.getViewport();
   1576   if (!this.enabled_ || !viewport.isZoomed())
   1577     return;
   1578   this.stopOperation();
   1579   viewport.setOffset(
   1580       viewport.getOffsetX() + event.wheelDeltaX,
   1581       viewport.getOffsetY() + event.wheelDeltaY);
   1582   this.slideMode_.applyViewportChange();
   1583 };
   1584