Gallery: Random fixes for the Viewport class.
[chromium-blink-merge.git] / ui / file_manager / gallery / js / slide_mode.js
blob2d48a8940a78ea894dcc7996c6c5d7541c81e9da
1 // Copyright 2014 The Chromium Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
5 'use strict';
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 {function(function())} toggleMode Function to toggle the Gallery mode.
19  * @param {function(string):string} displayStringFunction String formatting
20  *     function.
21  * @constructor
22  */
23 function SlideMode(container, content, toolbar, prompt,
24                    dataModel, selectionModel, context,
25                    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.metadataCache_ = context.metadataCache;
35   this.toggleMode_ = toggleMode;
36   this.displayStringFunction_ = displayStringFunction;
38   this.onSelectionBound_ = this.onSelection_.bind(this);
39   this.onSpliceBound_ = this.onSplice_.bind(this);
40   this.onContentBound_ = this.onContentChange_.bind(this);
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;
47   this.initListeners_();
48   this.initDom_();
51 /**
52  * SlideMode extends cr.EventTarget.
53  */
54 SlideMode.prototype.__proto__ = cr.EventTarget.prototype;
56 /**
57  * List of available editor modes.
58  * @type {Array.<ImageEditor.Mode>}
59  */
60 SlideMode.editorModes = [
61   new ImageEditor.Mode.InstantAutofix(),
62   new ImageEditor.Mode.Crop(),
63   new ImageEditor.Mode.Exposure(),
64   new ImageEditor.Mode.OneClick(
65       'rotate_left', 'GALLERY_ROTATE_LEFT', new Command.Rotate(-1)),
66   new ImageEditor.Mode.OneClick(
67       'rotate_right', 'GALLERY_ROTATE_RIGHT', new Command.Rotate(1))
70 /**
71  * @return {string} Mode name.
72  */
73 SlideMode.prototype.getName = function() { return 'slide'; };
75 /**
76  * @return {string} Mode title.
77  */
78 SlideMode.prototype.getTitle = function() { return 'GALLERY_SLIDE'; };
80 /**
81  * Initialize the listeners.
82  * @private
83  */
84 SlideMode.prototype.initListeners_ = function() {
85   window.addEventListener('resize', this.onResize_.bind(this));
88 /**
89  * Initialize the UI.
90  * @private
91  */
92 SlideMode.prototype.initDom_ = function() {
93   // Container for displayed image.
94   this.imageContainer_ = util.createChild(
95       this.document_.querySelector('.content'), 'image-container');
96   this.imageContainer_.addEventListener('click', this.onClick_.bind(this));
98   this.document_.addEventListener('click', this.onDocumentClick_.bind(this));
100   // Overwrite options and info bubble.
101   this.options_ = util.createChild(
102       this.toolbar_.querySelector('.filename-spacer'), 'options');
104   this.savedLabel_ = util.createChild(this.options_, 'saved');
105   this.savedLabel_.textContent = this.displayStringFunction_('GALLERY_SAVED');
107   var overwriteOriginalBox =
108       util.createChild(this.options_, 'overwrite-original');
110   this.overwriteOriginal_ = util.createChild(
111       overwriteOriginalBox, 'common white', 'input');
112   this.overwriteOriginal_.type = 'checkbox';
113   this.overwriteOriginal_.id = 'overwrite-checkbox';
114   util.platform.getPreference(SlideMode.OVERWRITE_KEY, function(value) {
115     // Out-of-the box default is 'true'
116     this.overwriteOriginal_.checked =
117         (typeof value !== 'string' || value === 'true');
118   }.bind(this));
119   this.overwriteOriginal_.addEventListener('click',
120       this.onOverwriteOriginalClick_.bind(this));
122   var overwriteLabel = util.createChild(overwriteOriginalBox, '', 'label');
123   overwriteLabel.textContent =
124       this.displayStringFunction_('GALLERY_OVERWRITE_ORIGINAL');
125   overwriteLabel.setAttribute('for', 'overwrite-checkbox');
127   this.bubble_ = util.createChild(this.toolbar_, 'bubble');
128   this.bubble_.hidden = true;
130   var bubbleContent = util.createChild(this.bubble_);
131   bubbleContent.innerHTML = this.displayStringFunction_(
132       'GALLERY_OVERWRITE_BUBBLE');
134   util.createChild(this.bubble_, 'pointer bottom', 'span');
136   var bubbleClose = util.createChild(this.bubble_, 'close-x');
137   bubbleClose.addEventListener('click', this.onCloseBubble_.bind(this));
139   // Ribbon and related controls.
140   this.arrowBox_ = util.createChild(this.container_, 'arrow-box');
142   this.arrowLeft_ =
143       util.createChild(this.arrowBox_, 'arrow left tool dimmable');
144   this.arrowLeft_.addEventListener('click',
145       this.advanceManually.bind(this, -1));
146   util.createChild(this.arrowLeft_);
148   util.createChild(this.arrowBox_, 'arrow-spacer');
150   this.arrowRight_ =
151       util.createChild(this.arrowBox_, 'arrow right tool dimmable');
152   this.arrowRight_.addEventListener('click',
153       this.advanceManually.bind(this, 1));
154   util.createChild(this.arrowRight_);
156   this.ribbonSpacer_ = util.createChild(this.toolbar_, 'ribbon-spacer');
157   this.ribbon_ = new Ribbon(
158       this.document_, this.dataModel_, this.selectionModel_);
159   this.ribbonSpacer_.appendChild(this.ribbon_);
161   // Error indicator.
162   var errorWrapper = util.createChild(this.container_, 'prompt-wrapper');
163   errorWrapper.setAttribute('pos', 'center');
165   this.errorBanner_ = util.createChild(errorWrapper, 'error-banner');
167   util.createChild(this.container_, 'spinner');
169   var slideShowButton = util.createChild(this.toolbar_,
170       'button slideshow', 'button');
171   slideShowButton.title = this.displayStringFunction_('GALLERY_SLIDESHOW');
172   slideShowButton.addEventListener('click',
173       this.startSlideshow.bind(this, SlideMode.SLIDESHOW_INTERVAL_FIRST));
175   var slideShowToolbar =
176       util.createChild(this.container_, 'tool slideshow-toolbar');
177   util.createChild(slideShowToolbar, 'slideshow-play').
178       addEventListener('click', this.toggleSlideshowPause_.bind(this));
179   util.createChild(slideShowToolbar, 'slideshow-end').
180       addEventListener('click', this.stopSlideshow_.bind(this));
182   // Editor.
184   this.editButton_ = util.createChild(this.toolbar_, 'button edit', 'button');
185   this.editButton_.title = this.displayStringFunction_('GALLERY_EDIT');
186   this.editButton_.setAttribute('disabled', '');  // Disabled by default.
187   this.editButton_.addEventListener('click', this.toggleEditor.bind(this));
189   this.printButton_ = util.createChild(this.toolbar_, 'button print', 'button');
190   this.printButton_.title = this.displayStringFunction_('GALLERY_PRINT');
191   this.printButton_.setAttribute('disabled', '');  // Disabled by default.
192   this.printButton_.addEventListener('click', this.print_.bind(this));
194   this.editBarSpacer_ = util.createChild(this.toolbar_, 'edit-bar-spacer');
195   this.editBarMain_ = util.createChild(this.editBarSpacer_, 'edit-main');
197   this.editBarMode_ = util.createChild(this.container_, 'edit-modal');
198   this.editBarModeWrapper_ = util.createChild(
199       this.editBarMode_, 'edit-modal-wrapper');
200   this.editBarModeWrapper_.hidden = true;
202   // Objects supporting image display and editing.
203   this.viewport_ = new Viewport();
205   this.imageView_ = new ImageView(
206       this.imageContainer_,
207       this.viewport_);
209   this.editor_ = new ImageEditor(
210       this.viewport_,
211       this.imageView_,
212       this.prompt_,
213       {
214         root: this.container_,
215         image: this.imageContainer_,
216         toolbar: this.editBarMain_,
217         mode: this.editBarModeWrapper_
218       },
219       SlideMode.editorModes,
220       this.displayStringFunction_,
221       this.onToolsVisibilityChanged_.bind(this));
223   this.editor_.getBuffer().addOverlay(
224       new SwipeOverlay(this.advanceManually.bind(this)));
228  * Load items, display the selected item.
229  * @param {Rect} zoomFromRect Rectangle for zoom effect.
230  * @param {function} displayCallback Called when the image is displayed.
231  * @param {function} loadCallback Called when the image is displayed.
232  */
233 SlideMode.prototype.enter = function(
234     zoomFromRect, displayCallback, loadCallback) {
235   this.sequenceDirection_ = 0;
236   this.sequenceLength_ = 0;
238   var loadDone = function(loadType, delay) {
239     this.active_ = true;
241     this.selectionModel_.addEventListener('change', this.onSelectionBound_);
242     this.dataModel_.addEventListener('splice', this.onSpliceBound_);
243     this.dataModel_.addEventListener('content', this.onContentBound_);
245     ImageUtil.setAttribute(this.arrowBox_, 'active', this.getItemCount_() > 1);
246     this.ribbon_.enable();
248     // Wait 1000ms after the animation is done, then prefetch the next image.
249     this.requestPrefetch(1, delay + 1000);
251     if (loadCallback) loadCallback();
252   }.bind(this);
254   // The latest |leave| call might have left the image animating. Remove it.
255   this.unloadImage_();
257   new Promise(function(fulfill) {
258     // If the items are empty, just show the error message.
259     if (this.getItemCount_() === 0) {
260       this.displayedIndex_ = -1;
261       //TODO(hirono) Show this message in the grid mode too.
262       this.showErrorBanner_('GALLERY_NO_IMAGES');
263       fulfill();
264       return;
265     }
267     // Remember the selection if it is empty or multiple. It will be restored
268     // in |leave| if the user did not changing the selection manually.
269     var currentSelection = this.selectionModel_.selectedIndexes;
270     if (currentSelection.length === 1)
271       this.savedSelection_ = null;
272     else
273       this.savedSelection_ = currentSelection;
275     // Ensure valid single selection.
276     // Note that the SlideMode object is not listening to selection change yet.
277     this.select(Math.max(0, this.getSelectedIndex()));
278     this.displayedIndex_ = this.getSelectedIndex();
280     // Show the selected item ASAP, then complete the initialization
281     // (loading the ribbon thumbnails can take some time).
282     var selectedItem = this.getSelectedItem();
284     // Load the image of the item.
285     this.loadItem_(
286         selectedItem,
287         zoomFromRect && this.imageView_.createZoomEffect(zoomFromRect),
288         displayCallback,
289         function(loadType, delay) {
290           fulfill(delay);
291         });
292   }.bind(this)).then(function(delay) {
293     // Turn the mode active.
294     this.active_ = true;
295     ImageUtil.setAttribute(this.arrowBox_, 'active', this.getItemCount_() > 1);
296     this.ribbon_.enable();
298     // Register handlers.
299     this.selectionModel_.addEventListener('change', this.onSelectionBound_);
300     this.dataModel_.addEventListener('splice', this.onSpliceBound_);
301     this.dataModel_.addEventListener('content', this.onContentBound_);
303     // Wait 1000ms after the animation is done, then prefetch the next image.
304     this.requestPrefetch(1, delay + 1000);
306     // Call load callback.
307     if (loadCallback)
308       loadCallback();
309   }.bind(this)).catch(function(error) {
310     console.error(error.stack, error);
311   });
315  * Leave the mode.
316  * @param {Rect} zoomToRect Rectangle for zoom effect.
317  * @param {function} callback Called when the image is committed and
318  *   the zoom-out animation has started.
319  */
320 SlideMode.prototype.leave = function(zoomToRect, callback) {
321   var commitDone = function() {
322       this.stopEditing_();
323       this.stopSlideshow_();
324       ImageUtil.setAttribute(this.arrowBox_, 'active', false);
325       this.selectionModel_.removeEventListener(
326           'change', this.onSelectionBound_);
327       this.dataModel_.removeEventListener('splice', this.onSpliceBound_);
328       this.dataModel_.removeEventListener('content', this.onContentBound_);
329       this.ribbon_.disable();
330       this.active_ = false;
331       if (this.savedSelection_)
332         this.selectionModel_.selectedIndexes = this.savedSelection_;
333       this.unloadImage_(zoomToRect);
334       callback();
335     }.bind(this);
337   this.viewport_.setZoomIndex(0);
338   if (this.getItemCount_() === 0) {
339     this.showErrorBanner_(false);
340     commitDone();
341   } else {
342     this.commitItem_(commitDone);
343   }
345   // Disable the slide-mode only buttons when leaving.
346   this.editButton_.setAttribute('disabled', '');
347   this.printButton_.setAttribute('disabled', '');
352  * Execute an action when the editor is not busy.
354  * @param {function} action Function to execute.
355  */
356 SlideMode.prototype.executeWhenReady = function(action) {
357   this.editor_.executeWhenReady(action);
361  * @return {boolean} True if the mode has active tools (that should not fade).
362  */
363 SlideMode.prototype.hasActiveTool = function() {
364   return this.isEditing();
368  * @return {number} Item count.
369  * @private
370  */
371 SlideMode.prototype.getItemCount_ = function() {
372   return this.dataModel_.length;
376  * @param {number} index Index.
377  * @return {Gallery.Item} Item.
378  */
379 SlideMode.prototype.getItem = function(index) {
380   return this.dataModel_.item(index);
384  * @return {Gallery.Item} Selected index.
385  */
386 SlideMode.prototype.getSelectedIndex = function() {
387   return this.selectionModel_.selectedIndex;
391  * @return {Rect} Screen rectangle of the selected image.
392  */
393 SlideMode.prototype.getSelectedImageRect = function() {
394   if (this.getSelectedIndex() < 0)
395     return null;
396   else
397     return this.viewport_.getImageBoundsOnScreen();
401  * @return {Gallery.Item} Selected item.
402  */
403 SlideMode.prototype.getSelectedItem = function() {
404   return this.getItem(this.getSelectedIndex());
408  * Toggles the full screen mode.
409  * @private
410  */
411 SlideMode.prototype.toggleFullScreen_ = function() {
412   util.toggleFullScreen(this.context_.appWindow,
413                         !util.isFullScreen(this.context_.appWindow));
417  * Selection change handler.
419  * Commits the current image and displays the newly selected image.
420  * @private
421  */
422 SlideMode.prototype.onSelection_ = function() {
423   if (this.selectionModel_.selectedIndexes.length === 0)
424     return;  // Temporary empty selection.
426   // Forget the saved selection if the user changed the selection manually.
427   if (!this.isSlideshowOn_())
428     this.savedSelection_ = null;
430   if (this.getSelectedIndex() === this.displayedIndex_)
431     return;  // Do not reselect.
433   this.commitItem_(this.loadSelectedItem_.bind(this));
437  * Handles changes in tools visibility, and if the header is dimmed, then
438  * requests disabling the draggable app region.
440  * @private
441  */
442 SlideMode.prototype.onToolsVisibilityChanged_ = function() {
443   var headerDimmed =
444       this.document_.querySelector('.header').hasAttribute('dimmed');
445   this.context_.onAppRegionChanged(!headerDimmed);
449  * Change the selection.
451  * @param {number} index New selected index.
452  * @param {number=} opt_slideHint Slide animation direction (-1|1).
453  */
454 SlideMode.prototype.select = function(index, opt_slideHint) {
455   this.slideHint_ = opt_slideHint;
456   this.selectionModel_.selectedIndex = index;
457   this.selectionModel_.leadIndex = index;
461  * Load the selected item.
463  * @private
464  */
465 SlideMode.prototype.loadSelectedItem_ = function() {
466   var slideHint = this.slideHint_;
467   this.slideHint_ = undefined;
469   var index = this.getSelectedIndex();
470   if (index === this.displayedIndex_)
471     return;  // Do not reselect.
473   var step = slideHint || (index - this.displayedIndex_);
475   if (Math.abs(step) != 1) {
476     // Long leap, the sequence is broken, we have no good prefetch candidate.
477     this.sequenceDirection_ = 0;
478     this.sequenceLength_ = 0;
479   } else if (this.sequenceDirection_ === step) {
480     // Keeping going in sequence.
481     this.sequenceLength_++;
482   } else {
483     // Reversed the direction. Reset the counter.
484     this.sequenceDirection_ = step;
485     this.sequenceLength_ = 1;
486   }
488   this.displayedIndex_ = index;
489   var selectedItem = this.getSelectedItem();
491   if (this.sequenceLength_ <= 1) {
492     // We have just broke the sequence. Touch the current image so that it stays
493     // in the cache longer.
494     this.imageView_.prefetch(selectedItem);
495   }
497   function shouldPrefetch(loadType, step, sequenceLength) {
498     // Never prefetch when selecting out of sequence.
499     if (Math.abs(step) != 1)
500       return false;
502     // Always prefetch if the previous load was from cache.
503     if (loadType === ImageView.LOAD_TYPE_CACHED_FULL)
504       return true;
506     // Prefetch if we have been going in the same direction for long enough.
507     return sequenceLength >= 3;
508   }
510   this.currentUniqueKey_++;
511   var selectedUniqueKey = this.currentUniqueKey_;
513   // Discard, since another load has been invoked after this one.
514   if (selectedUniqueKey != this.currentUniqueKey_)
515     return;
517   this.loadItem_(
518       selectedItem,
519       new ImageView.Effect.Slide(step, this.isSlideshowPlaying_()),
520       function() {} /* no displayCallback */,
521       function(loadType, delay) {
522         // Discard, since another load has been invoked after this one.
523         if (selectedUniqueKey != this.currentUniqueKey_)
524           return;
525         if (shouldPrefetch(loadType, step, this.sequenceLength_))
526           this.requestPrefetch(step, delay);
527         if (this.isSlideshowPlaying_())
528           this.scheduleNextSlide_();
529       }.bind(this));
533  * Unload the current image.
535  * @param {Rect} zoomToRect Rectangle for zoom effect.
536  * @private
537  */
538 SlideMode.prototype.unloadImage_ = function(zoomToRect) {
539   this.imageView_.unload(zoomToRect);
543  * Data model 'splice' event handler.
544  * @param {Event} event Event.
545  * @private
546  */
547 SlideMode.prototype.onSplice_ = function(event) {
548   ImageUtil.setAttribute(this.arrowBox_, 'active', this.getItemCount_() > 1);
550   // Splice invalidates saved indices, drop the saved selection.
551   this.savedSelection_ = null;
553   if (event.removed.length != 1)
554     return;
556   // Delay the selection to let the ribbon splice handler work first.
557   setTimeout(function() {
558     if (event.index < this.dataModel_.length) {
559       // There is the next item, select it.
560       // The next item is now at the same index as the removed one, so we need
561       // to correct displayIndex_ so that loadSelectedItem_ does not think
562       // we are re-selecting the same item (and does right-to-left slide-in
563       // animation).
564       this.displayedIndex_ = event.index - 1;
565       this.select(event.index);
566     } else if (this.dataModel_.length) {
567       // Removed item is the rightmost, but there are more items.
568       this.select(event.index - 1);  // Select the new last index.
569     } else {
570       // No items left. Unload the image and show the banner.
571       this.commitItem_(function() {
572         this.unloadImage_();
573         this.showErrorBanner_('GALLERY_NO_IMAGES');
574       }.bind(this));
575     }
576   }.bind(this), 0);
580  * @param {number} direction -1 for left, 1 for right.
581  * @return {number} Next index in the given direction, with wrapping.
582  * @private
583  */
584 SlideMode.prototype.getNextSelectedIndex_ = function(direction) {
585   function advance(index, limit) {
586     index += (direction > 0 ? 1 : -1);
587     if (index < 0)
588       return limit - 1;
589     if (index === limit)
590       return 0;
591     return index;
592   }
594   // If the saved selection is multiple the Slideshow should cycle through
595   // the saved selection.
596   if (this.isSlideshowOn_() &&
597       this.savedSelection_ && this.savedSelection_.length > 1) {
598     var pos = advance(this.savedSelection_.indexOf(this.getSelectedIndex()),
599         this.savedSelection_.length);
600     return this.savedSelection_[pos];
601   } else {
602     return advance(this.getSelectedIndex(), this.getItemCount_());
603   }
607  * Advance the selection based on the pressed key ID.
608  * @param {string} keyID Key identifier.
609  */
610 SlideMode.prototype.advanceWithKeyboard = function(keyID) {
611   var prev = (keyID === 'Up' ||
612               keyID === 'Left' ||
613               keyID === 'MediaPreviousTrack');
614   this.advanceManually(prev ? -1 : 1);
618  * Advance the selection as a result of a user action (as opposed to an
619  * automatic change in the slideshow mode).
620  * @param {number} direction -1 for left, 1 for right.
621  */
622 SlideMode.prototype.advanceManually = function(direction) {
623   if (this.isSlideshowPlaying_())
624     this.pauseSlideshow_();
625   cr.dispatchSimpleEvent(this, 'useraction');
626   this.selectNext(direction);
630  * Select the next item.
631  * @param {number} direction -1 for left, 1 for right.
632  */
633 SlideMode.prototype.selectNext = function(direction) {
634   this.select(this.getNextSelectedIndex_(direction), direction);
638  * Select the first item.
639  */
640 SlideMode.prototype.selectFirst = function() {
641   this.select(0);
645  * Select the last item.
646  */
647 SlideMode.prototype.selectLast = function() {
648   this.select(this.getItemCount_() - 1);
651 // Loading/unloading
654  * Load and display an item.
656  * @param {Gallery.Item} item Item.
657  * @param {Object} effect Transition effect object.
658  * @param {function} displayCallback Called when the image is displayed
659  *     (which can happen before the image load due to caching).
660  * @param {function} loadCallback Called when the image is fully loaded.
661  * @private
662  */
663 SlideMode.prototype.loadItem_ = function(
664     item, effect, displayCallback, loadCallback) {
665   var entry = item.getEntry();
666   var metadata = item.getMetadata();
667   this.showSpinner_(true);
669   var loadDone = function(loadType, delay, error) {
670     this.showSpinner_(false);
671     if (loadType === ImageView.LOAD_TYPE_ERROR) {
672       // if we have a specific error, then display it
673       if (error) {
674         this.showErrorBanner_(error);
675       } else {
676         // otherwise try to infer general error
677         this.showErrorBanner_('GALLERY_IMAGE_ERROR');
678       }
679     } else if (loadType === ImageView.LOAD_TYPE_OFFLINE) {
680       this.showErrorBanner_('GALLERY_IMAGE_OFFLINE');
681     }
683     ImageUtil.metrics.recordUserAction(ImageUtil.getMetricName('View'));
685     var toMillions = function(number) {
686       return Math.round(number / (1000 * 1000));
687     };
689     ImageUtil.metrics.recordSmallCount(ImageUtil.getMetricName('Size.MB'),
690         toMillions(metadata.filesystem.size));
692     var canvas = this.imageView_.getCanvas();
693     ImageUtil.metrics.recordSmallCount(ImageUtil.getMetricName('Size.MPix'),
694         toMillions(canvas.width * canvas.height));
696     var extIndex = entry.name.lastIndexOf('.');
697     var ext = extIndex < 0 ? '' :
698         entry.name.substr(extIndex + 1).toLowerCase();
699     if (ext === 'jpeg') ext = 'jpg';
700     ImageUtil.metrics.recordEnum(
701         ImageUtil.getMetricName('FileType'), ext, ImageUtil.FILE_TYPES);
703     // Enable or disable buttons for editing and printing.
704     if (error) {
705       this.editButton_.setAttribute('disabled', '');
706       this.printButton_.setAttribute('disabled', '');
707     } else {
708       this.editButton_.removeAttribute('disabled');
709       this.printButton_.removeAttribute('disabled');
710     }
712     // For once edited image, disallow the 'overwrite' setting change.
713     ImageUtil.setAttribute(this.options_, 'saved',
714         !this.getSelectedItem().isOriginal());
716     util.platform.getPreference(SlideMode.OVERWRITE_BUBBLE_KEY,
717         function(value) {
718           var times = typeof value === 'string' ? parseInt(value, 10) : 0;
719           if (times < SlideMode.OVERWRITE_BUBBLE_MAX_TIMES) {
720             this.bubble_.hidden = false;
721             if (this.isEditing()) {
722               util.platform.setPreference(
723                   SlideMode.OVERWRITE_BUBBLE_KEY, times + 1);
724             }
725           }
726         }.bind(this));
728     loadCallback(loadType, delay);
729   }.bind(this);
731   var displayDone = function() {
732     cr.dispatchSimpleEvent(this, 'image-displayed');
733     displayCallback();
734   }.bind(this);
736   this.editor_.openSession(
737       item, effect, this.saveCurrentImage_.bind(this), displayDone, loadDone);
741  * Commit changes to the current item and reset all messages/indicators.
743  * @param {function} callback Callback.
744  * @private
745  */
746 SlideMode.prototype.commitItem_ = function(callback) {
747   this.showSpinner_(false);
748   this.showErrorBanner_(false);
749   this.editor_.getPrompt().hide();
750   this.editor_.closeSession(callback);
754  * Request a prefetch for the next image.
756  * @param {number} direction -1 or 1.
757  * @param {number} delay Delay in ms. Used to prevent the CPU-heavy image
758  *   loading from disrupting the animation that might be still in progress.
759  */
760 SlideMode.prototype.requestPrefetch = function(direction, delay) {
761   if (this.getItemCount_() <= 1) return;
763   var index = this.getNextSelectedIndex_(direction);
764   this.imageView_.prefetch(this.getItem(index), delay);
767 // Event handlers.
770  * Unload handler, to be called from the top frame.
771  * @param {boolean} exiting True if the app is exiting.
772  */
773 SlideMode.prototype.onUnload = function(exiting) {
777  * Click handler for the image container.
779  * @param {Event} event Mouse click event.
780  * @private
781  */
782 SlideMode.prototype.onClick_ = function(event) {
786  * Click handler for the entire document.
787  * @param {Event} e Mouse click event.
788  * @private
789  */
790 SlideMode.prototype.onDocumentClick_ = function(e) {
791   // Close the bubble if clicked outside of it and if it is visible.
792   if (!this.bubble_.contains(e.target) &&
793       !this.editButton_.contains(e.target) &&
794       !this.arrowLeft_.contains(e.target) &&
795       !this.arrowRight_.contains(e.target) &&
796       !this.bubble_.hidden) {
797     this.bubble_.hidden = true;
798   }
802  * Keydown handler.
804  * @param {Event} event Event.
805  * @return {boolean} True if handled.
806  */
807 SlideMode.prototype.onKeyDown = function(event) {
808   var keyID = util.getKeyModifiers(event) + event.keyIdentifier;
810   if (this.isSlideshowOn_()) {
811     switch (keyID) {
812       case 'U+001B':  // Escape exits the slideshow.
813       case 'MediaStop':
814         this.stopSlideshow_(event);
815         break;
817       case 'U+0020':  // Space pauses/resumes the slideshow.
818       case 'MediaPlayPause':
819         this.toggleSlideshowPause_();
820         break;
822       case 'Up':
823       case 'Down':
824       case 'Left':
825       case 'Right':
826       case 'MediaNextTrack':
827       case 'MediaPreviousTrack':
828         this.advanceWithKeyboard(keyID);
829         break;
830     }
831     return true;  // Consume all keystrokes in the slideshow mode.
832   }
834   if (this.isEditing() && this.editor_.onKeyDown(event))
835     return true;
837   switch (keyID) {
838     case 'Ctrl-U+0050':  // Ctrl+'p' prints the current image.
839       if (!this.printButton_.hasAttribute('disabled'))
840         this.print_();
841       break;
843     case 'U+0045':  // 'e' toggles the editor.
844       if (!this.editButton_.hasAttribute('disabled'))
845         this.toggleEditor(event);
846       break;
848     case 'U+001B':  // Escape
849       if (!this.isEditing())
850         return false;  // Not handled.
851       this.toggleEditor(event);
852       break;
854     case 'Home':
855       this.selectFirst();
856       break;
857     case 'End':
858       this.selectLast();
859       break;
860     case 'Up':
861     case 'Down':
862     case 'Left':
863     case 'Right':
864     case 'MediaNextTrack':
865     case 'MediaPreviousTrack':
866       this.advanceWithKeyboard(keyID);
867       break;
869     case 'Ctrl-U+00BB':  // Ctrl+'=' zoom in.
870       if (!this.isEditing()) {
871         this.viewport_.setZoomIndex(this.viewport_.getZoomIndex() + 1);
872         this.imageView_.applyViewportChange();
873       }
874       break;
876     case 'Ctrl-U+00BD':  // Ctrl+'-' zoom out.
877       if (!this.isEditing()) {
878         this.viewport_.setZoomIndex(this.viewport_.getZoomIndex() - 1);
879         this.imageView_.applyViewportChange();
880       }
881       break;
882   }
884   return true;
888  * Resize handler.
889  * @private
890  */
891 SlideMode.prototype.onResize_ = function() {
892   this.viewport_.setScreenSize(
893       this.container_.clientWidth, this.container_.clientHeight);
894   this.editor_.getBuffer().draw();
898  * Update thumbnails.
899  */
900 SlideMode.prototype.updateThumbnails = function() {
901   this.ribbon_.reset();
902   if (this.active_)
903     this.ribbon_.redraw();
906 // Saving
909  * Save the current image to a file.
911  * @param {function} callback Callback.
912  * @private
913  */
914 SlideMode.prototype.saveCurrentImage_ = function(callback) {
915   this.showSpinner_(true);
917   var item = this.getSelectedItem();
918   var savedPromise = this.dataModel_.saveItem(
919       item,
920       this.imageView_.getCanvas(),
921       this.shouldOverwriteOriginal_());
923   savedPromise.catch(function(error) {
924     // TODO(hirono): Implement write error handling.
925     // Until then pretend that the save succeeded.
926     console.error(error.stack || error);
927   }).then(function() {
928     this.showSpinner_(false);
929     this.flashSavedLabel_();
931     // Allow changing the 'Overwrite original' setting only if the user
932     // used Undo to restore the original image AND it is not a copy.
933     // Otherwise lock the setting in its current state.
934     var mayChangeOverwrite = !this.editor_.canUndo() && item.isOriginal();
935     ImageUtil.setAttribute(this.options_, 'saved', !mayChangeOverwrite);
937     // Record UMA for the first edit.
938     if (this.imageView_.getContentRevision() === 1)
939       ImageUtil.metrics.recordUserAction(ImageUtil.getMetricName('Edit'));
941     callback();
942     cr.dispatchSimpleEvent(this, 'image-saved');
943   }.bind(this)).catch(function(error) {
944     console.error(error.stack || error);
945   });
949  * Update caches when the selected item has been renamed.
950  * @param {Event} event Event.
951  * @private
952  */
953 SlideMode.prototype.onContentChange_ = function(event) {
954   var newEntry = event.item.getEntry();
955   if (!util.isSameEntry(newEntry, event.oldEntry))
956     this.imageView_.changeEntry(newEntry);
960  * Flash 'Saved' label briefly to indicate that the image has been saved.
961  * @private
962  */
963 SlideMode.prototype.flashSavedLabel_ = function() {
964   var setLabelHighlighted =
965       ImageUtil.setAttribute.bind(null, this.savedLabel_, 'highlighted');
966   setTimeout(setLabelHighlighted.bind(null, true), 0);
967   setTimeout(setLabelHighlighted.bind(null, false), 300);
971  * Local storage key for the 'Overwrite original' setting.
972  * @type {string}
973  */
974 SlideMode.OVERWRITE_KEY = 'gallery-overwrite-original';
977  * Local storage key for the number of times that
978  * the overwrite info bubble has been displayed.
979  * @type {string}
980  */
981 SlideMode.OVERWRITE_BUBBLE_KEY = 'gallery-overwrite-bubble';
984  * Max number that the overwrite info bubble is shown.
985  * @type {number}
986  */
987 SlideMode.OVERWRITE_BUBBLE_MAX_TIMES = 5;
990  * @return {boolean} True if 'Overwrite original' is set.
991  * @private
992  */
993 SlideMode.prototype.shouldOverwriteOriginal_ = function() {
994    return this.overwriteOriginal_.checked;
998  * 'Overwrite original' checkbox handler.
999  * @param {Event} event Event.
1000  * @private
1001  */
1002 SlideMode.prototype.onOverwriteOriginalClick_ = function(event) {
1003   util.platform.setPreference(SlideMode.OVERWRITE_KEY, event.target.checked);
1007  * Overwrite info bubble close handler.
1008  * @private
1009  */
1010 SlideMode.prototype.onCloseBubble_ = function() {
1011   this.bubble_.hidden = true;
1012   util.platform.setPreference(SlideMode.OVERWRITE_BUBBLE_KEY,
1013       SlideMode.OVERWRITE_BUBBLE_MAX_TIMES);
1016 // Slideshow
1019  * Slideshow interval in ms.
1020  */
1021 SlideMode.SLIDESHOW_INTERVAL = 5000;
1024  * First slideshow interval in ms. It should be shorter so that the user
1025  * is not guessing whether the button worked.
1026  */
1027 SlideMode.SLIDESHOW_INTERVAL_FIRST = 1000;
1030  * Empirically determined duration of the fullscreen toggle animation.
1031  */
1032 SlideMode.FULLSCREEN_TOGGLE_DELAY = 500;
1035  * @return {boolean} True if the slideshow is on.
1036  * @private
1037  */
1038 SlideMode.prototype.isSlideshowOn_ = function() {
1039   return this.container_.hasAttribute('slideshow');
1043  * Start the slideshow.
1044  * @param {number=} opt_interval First interval in ms.
1045  * @param {Event=} opt_event Event.
1046  */
1047 SlideMode.prototype.startSlideshow = function(opt_interval, opt_event) {
1048   // Reset zoom.
1049   this.viewport_.setZoomIndex(0);
1050   this.imageView_.applyViewportChange();
1052   // Set the attribute early to prevent the toolbar from flashing when
1053   // the slideshow is being started from the mosaic view.
1054   this.container_.setAttribute('slideshow', 'playing');
1056   if (this.active_) {
1057     this.stopEditing_();
1058   } else {
1059     // We are in the Mosaic mode. Toggle the mode but remember to return.
1060     this.leaveAfterSlideshow_ = true;
1061     this.toggleMode_(this.startSlideshow.bind(
1062         this, SlideMode.SLIDESHOW_INTERVAL, opt_event));
1063     return;
1064   }
1066   if (opt_event)  // Caused by user action, notify the Gallery.
1067     cr.dispatchSimpleEvent(this, 'useraction');
1069   this.fullscreenBeforeSlideshow_ = util.isFullScreen(this.context_.appWindow);
1070   if (!this.fullscreenBeforeSlideshow_) {
1071     // Wait until the zoom animation from the mosaic mode is done.
1072     setTimeout(this.toggleFullScreen_.bind(this),
1073                ImageView.ZOOM_ANIMATION_DURATION);
1074     opt_interval = (opt_interval || SlideMode.SLIDESHOW_INTERVAL) +
1075         SlideMode.FULLSCREEN_TOGGLE_DELAY;
1076   }
1078   this.resumeSlideshow_(opt_interval);
1082  * Stop the slideshow.
1083  * @param {Event=} opt_event Event.
1084  * @private
1085  */
1086 SlideMode.prototype.stopSlideshow_ = function(opt_event) {
1087   if (!this.isSlideshowOn_())
1088     return;
1090   if (opt_event)  // Caused by user action, notify the Gallery.
1091     cr.dispatchSimpleEvent(this, 'useraction');
1093   this.pauseSlideshow_();
1094   this.container_.removeAttribute('slideshow');
1096   // Do not restore fullscreen if we exited fullscreen while in slideshow.
1097   var fullscreen = util.isFullScreen(this.context_.appWindow);
1098   var toggleModeDelay = 0;
1099   if (!this.fullscreenBeforeSlideshow_ && fullscreen) {
1100     this.toggleFullScreen_();
1101     toggleModeDelay = SlideMode.FULLSCREEN_TOGGLE_DELAY;
1102   }
1103   if (this.leaveAfterSlideshow_) {
1104     this.leaveAfterSlideshow_ = false;
1105     setTimeout(this.toggleMode_.bind(this), toggleModeDelay);
1106   }
1110  * @return {boolean} True if the slideshow is playing (not paused).
1111  * @private
1112  */
1113 SlideMode.prototype.isSlideshowPlaying_ = function() {
1114   return this.container_.getAttribute('slideshow') === 'playing';
1118  * Pause/resume the slideshow.
1119  * @private
1120  */
1121 SlideMode.prototype.toggleSlideshowPause_ = function() {
1122   cr.dispatchSimpleEvent(this, 'useraction');  // Show the tools.
1123   if (this.isSlideshowPlaying_()) {
1124     this.pauseSlideshow_();
1125   } else {
1126     this.resumeSlideshow_(SlideMode.SLIDESHOW_INTERVAL_FIRST);
1127   }
1131  * @param {number=} opt_interval Slideshow interval in ms.
1132  * @private
1133  */
1134 SlideMode.prototype.scheduleNextSlide_ = function(opt_interval) {
1135   console.assert(this.isSlideshowPlaying_(), 'Inconsistent slideshow state');
1137   if (this.slideShowTimeout_)
1138     clearTimeout(this.slideShowTimeout_);
1140   this.slideShowTimeout_ = setTimeout(function() {
1141         this.slideShowTimeout_ = null;
1142         this.selectNext(1);
1143       }.bind(this),
1144       opt_interval || SlideMode.SLIDESHOW_INTERVAL);
1148  * Resume the slideshow.
1149  * @param {number=} opt_interval Slideshow interval in ms.
1150  * @private
1151  */
1152 SlideMode.prototype.resumeSlideshow_ = function(opt_interval) {
1153   this.container_.setAttribute('slideshow', 'playing');
1154   this.scheduleNextSlide_(opt_interval);
1158  * Pause the slideshow.
1159  * @private
1160  */
1161 SlideMode.prototype.pauseSlideshow_ = function() {
1162   this.container_.setAttribute('slideshow', 'paused');
1163   if (this.slideShowTimeout_) {
1164     clearTimeout(this.slideShowTimeout_);
1165     this.slideShowTimeout_ = null;
1166   }
1170  * @return {boolean} True if the editor is active.
1171  */
1172 SlideMode.prototype.isEditing = function() {
1173   return this.container_.hasAttribute('editing');
1177  * Stop editing.
1178  * @private
1179  */
1180 SlideMode.prototype.stopEditing_ = function() {
1181   if (this.isEditing())
1182     this.toggleEditor();
1186  * Activate/deactivate editor.
1187  * @param {Event=} opt_event Event.
1188  */
1189 SlideMode.prototype.toggleEditor = function(opt_event) {
1190   if (opt_event)  // Caused by user action, notify the Gallery.
1191     cr.dispatchSimpleEvent(this, 'useraction');
1193   if (!this.active_) {
1194     this.toggleMode_(this.toggleEditor.bind(this));
1195     return;
1196   }
1198   this.stopSlideshow_();
1200   ImageUtil.setAttribute(this.container_, 'editing', !this.isEditing());
1202   if (this.isEditing()) { // isEditing has just been flipped to a new value.
1203     // Reset zoom.
1204     this.viewport_.setZoomIndex(0);
1205     this.imageView_.applyViewportChange();
1206     if (this.context_.readonlyDirName) {
1207       this.editor_.getPrompt().showAt(
1208           'top', 'GALLERY_READONLY_WARNING', 0, this.context_.readonlyDirName);
1209     }
1210   } else {
1211     this.editor_.getPrompt().hide();
1212     this.editor_.leaveModeGently();
1213   }
1217  * Prints the current item.
1218  * @private
1219  */
1220 SlideMode.prototype.print_ = function() {
1221   cr.dispatchSimpleEvent(this, 'useraction');
1222   window.print();
1226  * Display the error banner.
1227  * @param {string} message Message.
1228  * @private
1229  */
1230 SlideMode.prototype.showErrorBanner_ = function(message) {
1231   if (message) {
1232     this.errorBanner_.textContent = this.displayStringFunction_(message);
1233   }
1234   ImageUtil.setAttribute(this.container_, 'error', !!message);
1238  * Show/hide the busy spinner.
1240  * @param {boolean} on True if show, false if hide.
1241  * @private
1242  */
1243 SlideMode.prototype.showSpinner_ = function(on) {
1244   if (this.spinnerTimer_) {
1245     clearTimeout(this.spinnerTimer_);
1246     this.spinnerTimer_ = null;
1247   }
1249   if (on) {
1250     this.spinnerTimer_ = setTimeout(function() {
1251       this.spinnerTimer_ = null;
1252       ImageUtil.setAttribute(this.container_, 'spinner', true);
1253     }.bind(this), 1000);
1254   } else {
1255     ImageUtil.setAttribute(this.container_, 'spinner', false);
1256   }
1260  * Overlay that handles swipe gestures. Changes to the next or previous file.
1261  * @param {function(number)} callback A callback accepting the swipe direction
1262  *    (1 means left, -1 right).
1263  * @constructor
1264  * @implements {ImageBuffer.Overlay}
1265  */
1266 function SwipeOverlay(callback) {
1267   this.callback_ = callback;
1271  * Inherit ImageBuffer.Overlay.
1272  */
1273 SwipeOverlay.prototype.__proto__ = ImageBuffer.Overlay.prototype;
1276  * @param {number} x X pointer position.
1277  * @param {number} y Y pointer position.
1278  * @param {boolean} touch True if dragging caused by touch.
1279  * @return {function} The closure to call on drag.
1280  */
1281 SwipeOverlay.prototype.getDragHandler = function(x, y, touch) {
1282   if (!touch)
1283     return null;
1284   var origin = x;
1285   var done = false;
1286   return function(x, y) {
1287     if (!done && origin - x > SwipeOverlay.SWIPE_THRESHOLD) {
1288       this.callback_(1);
1289       done = true;
1290     } else if (!done && x - origin > SwipeOverlay.SWIPE_THRESHOLD) {
1291       this.callback_(-1);
1292       done = true;
1293     }
1294   }.bind(this);
1298  * If the user touched the image and moved the finger more than SWIPE_THRESHOLD
1299  * horizontally it's considered as a swipe gesture (change the current image).
1300  */
1301 SwipeOverlay.SWIPE_THRESHOLD = 100;