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 */
24function 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 */
56SlideMode.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 */
71SlideMode.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 */
81SlideMode.prototype.__proto__ = cr.EventTarget.prototype;
82
83/**
84 * @return {string} Mode name.
85 */
86SlideMode.prototype.getName = function() { return 'slide'; };
87
88/**
89 * @return {string} Mode title.
90 */
91SlideMode.prototype.getTitle = function() { return 'GALLERY_SLIDE'; };
92
93/**
94 * @return {Viewport} Viewport.
95 */
96SlideMode.prototype.getViewport = function() { return this.viewport_; };
97
98/**
99 * Initialize the listeners.
100 * @private
101 */
102SlideMode.prototype.initListeners_ = function() {
103  window.addEventListener('resize', this.onResize_.bind(this));
104};
105
106/**
107 * Initialize the UI.
108 * @private
109 */
110SlideMode.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 */
250SlideMode.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 */
336SlideMode.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 */
374SlideMode.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 */
381SlideMode.prototype.hasActiveTool = function() {
382  return this.isEditing();
383};
384
385/**
386 * @return {number} Item count.
387 * @private
388 */
389SlideMode.prototype.getItemCount_ = function() {
390  return this.dataModel_.length;
391};
392
393/**
394 * @param {number} index Index.
395 * @return {Gallery.Item} Item.
396 */
397SlideMode.prototype.getItem = function(index) {
398  return this.dataModel_.item(index);
399};
400
401/**
402 * @return {Gallery.Item} Selected index.
403 */
404SlideMode.prototype.getSelectedIndex = function() {
405  return this.selectionModel_.selectedIndex;
406};
407
408/**
409 * @return {Rect} Screen rectangle of the selected image.
410 */
411SlideMode.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 */
421SlideMode.prototype.getSelectedItem = function() {
422  return this.getItem(this.getSelectedIndex());
423};
424
425/**
426 * Toggles the full screen mode.
427 * @private
428 */
429SlideMode.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 */
440SlideMode.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 */
460SlideMode.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 */
472SlideMode.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 */
483SlideMode.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 */
556SlideMode.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 */
565SlideMode.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 */
602SlideMode.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 */
628SlideMode.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 */
640SlideMode.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 */
651SlideMode.prototype.selectNext = function(direction) {
652  this.select(this.getNextSelectedIndex_(direction), direction);
653};
654
655/**
656 * Select the first item.
657 */
658SlideMode.prototype.selectFirst = function() {
659  this.select(0);
660};
661
662/**
663 * Select the last item.
664 */
665SlideMode.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 */
681SlideMode.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 */
769SlideMode.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 */
783SlideMode.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 */
796SlideMode.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 */
805SlideMode.prototype.onClick_ = function(event) {
806};
807
808/**
809 * Click handler for the entire document.
810 * @param {Event} e Mouse click event.
811 * @private
812 */
813SlideMode.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 */
830SlideMode.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 */
943SlideMode.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 */
953SlideMode.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 */
968SlideMode.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 */
1006SlideMode.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 */
1017SlideMode.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 */
1024SlideMode.OVERWRITE_BUBBLE_KEY = 'gallery-overwrite-bubble';
1025
1026/**
1027 * Max number that the overwrite info bubble is shown.
1028 * @type {number}
1029 */
1030SlideMode.OVERWRITE_BUBBLE_MAX_TIMES = 5;
1031
1032/**
1033 * @return {boolean} True if 'Overwrite original' is set.
1034 * @private
1035 */
1036SlideMode.prototype.shouldOverwriteOriginal_ = function() {
1037  return this.overwriteOriginal_.checked;
1038};
1039
1040/**
1041 * 'Overwrite original' checkbox handler.
1042 * @param {Event} event Event.
1043 * @private
1044 */
1045SlideMode.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 */
1055SlideMode.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 */
1068SlideMode.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 */
1074SlideMode.SLIDESHOW_INTERVAL_FIRST = 1000;
1075
1076/**
1077 * Empirically determined duration of the fullscreen toggle animation.
1078 */
1079SlideMode.FULLSCREEN_TOGGLE_DELAY = 500;
1080
1081/**
1082 * @return {boolean} True if the slideshow is on.
1083 * @private
1084 */
1085SlideMode.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 */
1094SlideMode.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 */
1136SlideMode.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 */
1166SlideMode.prototype.isSlideshowPlaying_ = function() {
1167  return this.container_.getAttribute('slideshow') === 'playing';
1168};
1169
1170/**
1171 * Pauses/resumes the slideshow.
1172 * @private
1173 */
1174SlideMode.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 */
1187SlideMode.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 */
1204SlideMode.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 */
1213SlideMode.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 */
1224SlideMode.prototype.isEditing = function() {
1225  return this.container_.hasAttribute('editing');
1226};
1227
1228/**
1229 * Stops editing.
1230 * @private
1231 */
1232SlideMode.prototype.stopEditing_ = function() {
1233  if (this.isEditing())
1234    this.toggleEditor();
1235};
1236
1237/**
1238 * Activate/deactivate editor.
1239 * @param {Event=} opt_event Event.
1240 */
1241SlideMode.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 */
1274SlideMode.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 */
1284SlideMode.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 */
1297SlideMode.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 */
1316SlideMode.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 */
1326function 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 */
1407TouchHandler.SWIPE_THRESHOLD = 100;
1408
1409/**
1410 * Rotation threshold in degrees.
1411 * @type {number}
1412 * @const
1413 */
1414TouchHandler.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 */
1422TouchHandler.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 */
1438TouchHandler.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
1448TouchHandler.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 */
1462TouchHandler.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 */
1475TouchHandler.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 */
1485TouchHandler.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 */
1574TouchHandler.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