// Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. 'use strict'; /** * Slide mode displays a single image and has a set of controls to navigate * between the images and to edit an image. * * TODO(kaznacheev): Introduce a parameter object. * * @param {Element} container Main container element. * @param {Element} content Content container element. * @param {Element} toolbar Toolbar element. * @param {ImageEditor.Prompt} prompt Prompt. * @param {cr.ui.ArrayDataModel} dataModel Data model. * @param {cr.ui.ListSelectionModel} selectionModel Selection model. * @param {Object} context Context. * @param {function(function())} toggleMode Function to toggle the Gallery mode. * @param {function(string):string} displayStringFunction String formatting * function. * @constructor */ function SlideMode(container, content, toolbar, prompt, dataModel, selectionModel, context, toggleMode, displayStringFunction) { this.container_ = container; this.document_ = container.ownerDocument; this.content = content; this.toolbar_ = toolbar; this.prompt_ = prompt; this.dataModel_ = dataModel; this.selectionModel_ = selectionModel; this.context_ = context; this.metadataCache_ = context.metadataCache; this.toggleMode_ = toggleMode; this.displayStringFunction_ = displayStringFunction; this.onSelectionBound_ = this.onSelection_.bind(this); this.onSpliceBound_ = this.onSplice_.bind(this); this.onContentBound_ = this.onContentChange_.bind(this); // Unique numeric key, incremented per each load attempt used to discard // old attempts. This can happen especially when changing selection fast or // Internet connection is slow. this.currentUniqueKey_ = 0; this.initListeners_(); this.initDom_(); } /** * SlideMode extends cr.EventTarget. */ SlideMode.prototype.__proto__ = cr.EventTarget.prototype; /** * List of available editor modes. * @type {Array.} */ SlideMode.editorModes = [ new ImageEditor.Mode.InstantAutofix(), new ImageEditor.Mode.Crop(), new ImageEditor.Mode.Exposure(), new ImageEditor.Mode.OneClick( 'rotate_left', 'GALLERY_ROTATE_LEFT', new Command.Rotate(-1)), new ImageEditor.Mode.OneClick( 'rotate_right', 'GALLERY_ROTATE_RIGHT', new Command.Rotate(1)) ]; /** * @return {string} Mode name. */ SlideMode.prototype.getName = function() { return 'slide' }; /** * @return {string} Mode title. */ SlideMode.prototype.getTitle = function() { return 'GALLERY_SLIDE' }; /** * Initialize the listeners. * @private */ SlideMode.prototype.initListeners_ = function() { window.addEventListener('resize', this.onResize_.bind(this), false); }; /** * Initialize the UI. * @private */ SlideMode.prototype.initDom_ = function() { // Container for displayed image or video. this.imageContainer_ = util.createChild( this.document_.querySelector('.content'), 'image-container'); this.imageContainer_.addEventListener('click', this.onClick_.bind(this)); this.document_.addEventListener('click', this.onDocumentClick_.bind(this)); // Overwrite options and info bubble. this.options_ = util.createChild( this.toolbar_.querySelector('.filename-spacer'), 'options'); this.savedLabel_ = util.createChild(this.options_, 'saved'); this.savedLabel_.textContent = this.displayStringFunction_('GALLERY_SAVED'); var overwriteOriginalBox = util.createChild(this.options_, 'overwrite-original'); this.overwriteOriginal_ = util.createChild( overwriteOriginalBox, 'common white', 'input'); this.overwriteOriginal_.type = 'checkbox'; this.overwriteOriginal_.id = 'overwrite-checkbox'; util.platform.getPreference(SlideMode.OVERWRITE_KEY, function(value) { // Out-of-the box default is 'true' this.overwriteOriginal_.checked = (typeof value !== 'string' || value === 'true'); }.bind(this)); this.overwriteOriginal_.addEventListener('click', this.onOverwriteOriginalClick_.bind(this)); var overwriteLabel = util.createChild(overwriteOriginalBox, '', 'label'); overwriteLabel.textContent = this.displayStringFunction_('GALLERY_OVERWRITE_ORIGINAL'); overwriteLabel.setAttribute('for', 'overwrite-checkbox'); this.bubble_ = util.createChild(this.toolbar_, 'bubble'); this.bubble_.hidden = true; var bubbleContent = util.createChild(this.bubble_); bubbleContent.innerHTML = this.displayStringFunction_( 'GALLERY_OVERWRITE_BUBBLE'); util.createChild(this.bubble_, 'pointer bottom', 'span'); var bubbleClose = util.createChild(this.bubble_, 'close-x'); bubbleClose.addEventListener('click', this.onCloseBubble_.bind(this)); // Video player controls. this.mediaSpacer_ = util.createChild(this.container_, 'video-controls-spacer'); this.mediaToolbar_ = util.createChild(this.mediaSpacer_, 'tool'); this.mediaControls_ = new VideoControls( this.mediaToolbar_, this.showErrorBanner_.bind(this, 'GALLERY_VIDEO_ERROR'), this.displayStringFunction_.bind(this), this.toggleFullScreen_.bind(this), this.container_); // Ribbon and related controls. this.arrowBox_ = util.createChild(this.container_, 'arrow-box'); this.arrowLeft_ = util.createChild(this.arrowBox_, 'arrow left tool dimmable'); this.arrowLeft_.addEventListener('click', this.advanceManually.bind(this, -1)); util.createChild(this.arrowLeft_); util.createChild(this.arrowBox_, 'arrow-spacer'); this.arrowRight_ = util.createChild(this.arrowBox_, 'arrow right tool dimmable'); this.arrowRight_.addEventListener('click', this.advanceManually.bind(this, 1)); util.createChild(this.arrowRight_); this.ribbonSpacer_ = util.createChild(this.toolbar_, 'ribbon-spacer'); this.ribbon_ = new Ribbon(this.document_, this.metadataCache_, this.dataModel_, this.selectionModel_); this.ribbonSpacer_.appendChild(this.ribbon_); // Error indicator. var errorWrapper = util.createChild(this.container_, 'prompt-wrapper'); errorWrapper.setAttribute('pos', 'center'); this.errorBanner_ = util.createChild(errorWrapper, 'error-banner'); util.createChild(this.container_, 'spinner'); var slideShowButton = util.createChild(this.toolbar_, 'button slideshow', 'button'); slideShowButton.title = this.displayStringFunction_('GALLERY_SLIDESHOW'); slideShowButton.addEventListener('click', this.startSlideshow.bind(this, SlideMode.SLIDESHOW_INTERVAL_FIRST)); var slideShowToolbar = util.createChild(this.container_, 'tool slideshow-toolbar'); util.createChild(slideShowToolbar, 'slideshow-play'). addEventListener('click', this.toggleSlideshowPause_.bind(this)); util.createChild(slideShowToolbar, 'slideshow-end'). addEventListener('click', this.stopSlideshow_.bind(this)); // Editor. this.editButton_ = util.createChild(this.toolbar_, 'button edit', 'button'); this.editButton_.title = this.displayStringFunction_('GALLERY_EDIT'); this.editButton_.setAttribute('disabled', ''); // Disabled by default. this.editButton_.addEventListener('click', this.toggleEditor.bind(this)); this.printButton_ = util.createChild(this.toolbar_, 'button print', 'button'); this.printButton_.title = this.displayStringFunction_('GALLERY_PRINT'); this.printButton_.setAttribute('disabled', ''); // Disabled by default. this.printButton_.addEventListener('click', this.print_.bind(this)); this.editBarSpacer_ = util.createChild(this.toolbar_, 'edit-bar-spacer'); this.editBarMain_ = util.createChild(this.editBarSpacer_, 'edit-main'); this.editBarMode_ = util.createChild(this.container_, 'edit-modal'); this.editBarModeWrapper_ = util.createChild( this.editBarMode_, 'edit-modal-wrapper'); this.editBarModeWrapper_.hidden = true; // Objects supporting image display and editing. this.viewport_ = new Viewport(); this.imageView_ = new ImageView( this.imageContainer_, this.viewport_, this.metadataCache_); this.editor_ = new ImageEditor( this.viewport_, this.imageView_, this.prompt_, { root: this.container_, image: this.imageContainer_, toolbar: this.editBarMain_, mode: this.editBarModeWrapper_ }, SlideMode.editorModes, this.displayStringFunction_, this.onToolsVisibilityChanged_.bind(this)); this.editor_.getBuffer().addOverlay( new SwipeOverlay(this.advanceManually.bind(this))); }; /** * Load items, display the selected item. * @param {Rect} zoomFromRect Rectangle for zoom effect. * @param {function} displayCallback Called when the image is displayed. * @param {function} loadCallback Called when the image is displayed. */ SlideMode.prototype.enter = function( zoomFromRect, displayCallback, loadCallback) { this.sequenceDirection_ = 0; this.sequenceLength_ = 0; var loadDone = function(loadType, delay) { this.active_ = true; this.selectionModel_.addEventListener('change', this.onSelectionBound_); this.dataModel_.addEventListener('splice', this.onSpliceBound_); this.dataModel_.addEventListener('content', this.onContentBound_); ImageUtil.setAttribute(this.arrowBox_, 'active', this.getItemCount_() > 1); this.ribbon_.enable(); // Wait 1000ms after the animation is done, then prefetch the next image. this.requestPrefetch(1, delay + 1000); if (loadCallback) loadCallback(); }.bind(this); // The latest |leave| call might have left the image animating. Remove it. this.unloadImage_(); if (this.getItemCount_() === 0) { this.displayedIndex_ = -1; //TODO(kaznacheev) Show this message in the grid mode too. this.showErrorBanner_('GALLERY_NO_IMAGES'); loadDone(); } else { // Remember the selection if it is empty or multiple. It will be restored // in |leave| if the user did not changing the selection manually. var currentSelection = this.selectionModel_.selectedIndexes; if (currentSelection.length === 1) this.savedSelection_ = null; else this.savedSelection_ = currentSelection; // Ensure valid single selection. // Note that the SlideMode object is not listening to selection change yet. this.select(Math.max(0, this.getSelectedIndex())); this.displayedIndex_ = this.getSelectedIndex(); var selectedItem = this.getSelectedItem(); // Show the selected item ASAP, then complete the initialization // (loading the ribbon thumbnails can take some time). this.metadataCache_.getOne(selectedItem.getEntry(), Gallery.METADATA_TYPE, function(metadata) { this.loadItem_(selectedItem.getEntry(), metadata, zoomFromRect && this.imageView_.createZoomEffect(zoomFromRect), displayCallback, loadDone); }.bind(this)); } }; /** * Leave the mode. * @param {Rect} zoomToRect Rectangle for zoom effect. * @param {function} callback Called when the image is committed and * the zoom-out animation has started. */ SlideMode.prototype.leave = function(zoomToRect, callback) { var commitDone = function() { this.stopEditing_(); this.stopSlideshow_(); ImageUtil.setAttribute(this.arrowBox_, 'active', false); this.selectionModel_.removeEventListener( 'change', this.onSelectionBound_); this.dataModel_.removeEventListener('splice', this.onSpliceBound_); this.dataModel_.removeEventListener('content', this.onContentBound_); this.ribbon_.disable(); this.active_ = false; if (this.savedSelection_) this.selectionModel_.selectedIndexes = this.savedSelection_; this.unloadImage_(zoomToRect); callback(); }.bind(this); if (this.getItemCount_() === 0) { this.showErrorBanner_(false); commitDone(); } else { this.commitItem_(commitDone); } // Disable the slide-mode only buttons when leaving. this.editButton_.setAttribute('disabled', ''); this.printButton_.setAttribute('disabled', ''); }; /** * Execute an action when the editor is not busy. * * @param {function} action Function to execute. */ SlideMode.prototype.executeWhenReady = function(action) { this.editor_.executeWhenReady(action); }; /** * @return {boolean} True if the mode has active tools (that should not fade). */ SlideMode.prototype.hasActiveTool = function() { return this.isEditing(); }; /** * @return {number} Item count. * @private */ SlideMode.prototype.getItemCount_ = function() { return this.dataModel_.length; }; /** * @param {number} index Index. * @return {Gallery.Item} Item. */ SlideMode.prototype.getItem = function(index) { return this.dataModel_.item(index); }; /** * @return {Gallery.Item} Selected index. */ SlideMode.prototype.getSelectedIndex = function() { return this.selectionModel_.selectedIndex; }; /** * @return {Rect} Screen rectangle of the selected image. */ SlideMode.prototype.getSelectedImageRect = function() { if (this.getSelectedIndex() < 0) return null; else return this.viewport_.getScreenClipped(); }; /** * @return {Gallery.Item} Selected item. */ SlideMode.prototype.getSelectedItem = function() { return this.getItem(this.getSelectedIndex()); }; /** * Toggles the full screen mode. * @private */ SlideMode.prototype.toggleFullScreen_ = function() { util.toggleFullScreen(this.context_.appWindow, !util.isFullScreen(this.context_.appWindow)); }; /** * Selection change handler. * * Commits the current image and displays the newly selected image. * @private */ SlideMode.prototype.onSelection_ = function() { if (this.selectionModel_.selectedIndexes.length === 0) return; // Temporary empty selection. // Forget the saved selection if the user changed the selection manually. if (!this.isSlideshowOn_()) this.savedSelection_ = null; if (this.getSelectedIndex() === this.displayedIndex_) return; // Do not reselect. this.commitItem_(this.loadSelectedItem_.bind(this)); }; /** * Handles changes in tools visibility, and if the header is dimmed, then * requests disabling the draggable app region. * * @private */ SlideMode.prototype.onToolsVisibilityChanged_ = function() { var headerDimmed = this.document_.querySelector('.header').hasAttribute('dimmed'); this.context_.onAppRegionChanged(!headerDimmed); }; /** * Change the selection. * * @param {number} index New selected index. * @param {number=} opt_slideHint Slide animation direction (-1|1). */ SlideMode.prototype.select = function(index, opt_slideHint) { this.slideHint_ = opt_slideHint; this.selectionModel_.selectedIndex = index; this.selectionModel_.leadIndex = index; }; /** * Load the selected item. * * @private */ SlideMode.prototype.loadSelectedItem_ = function() { var slideHint = this.slideHint_; this.slideHint_ = undefined; var index = this.getSelectedIndex(); if (index === this.displayedIndex_) return; // Do not reselect. var step = slideHint || (index - this.displayedIndex_); if (Math.abs(step) != 1) { // Long leap, the sequence is broken, we have no good prefetch candidate. this.sequenceDirection_ = 0; this.sequenceLength_ = 0; } else if (this.sequenceDirection_ === step) { // Keeping going in sequence. this.sequenceLength_++; } else { // Reversed the direction. Reset the counter. this.sequenceDirection_ = step; this.sequenceLength_ = 1; } if (this.sequenceLength_ <= 1) { // We have just broke the sequence. Touch the current image so that it stays // in the cache longer. this.imageView_.prefetch(this.imageView_.contentEntry_); } this.displayedIndex_ = index; function shouldPrefetch(loadType, step, sequenceLength) { // Never prefetch when selecting out of sequence. if (Math.abs(step) != 1) return false; // Never prefetch after a video load (decoding the next image can freeze // the UI for a second or two). if (loadType === ImageView.LOAD_TYPE_VIDEO_FILE) return false; // Always prefetch if the previous load was from cache. if (loadType === ImageView.LOAD_TYPE_CACHED_FULL) return true; // Prefetch if we have been going in the same direction for long enough. return sequenceLength >= 3; } var selectedItem = this.getSelectedItem(); this.currentUniqueKey_++; var selectedUniqueKey = this.currentUniqueKey_; var onMetadata = function(metadata) { // Discard, since another load has been invoked after this one. if (selectedUniqueKey != this.currentUniqueKey_) return; this.loadItem_(selectedItem.getEntry(), metadata, new ImageView.Effect.Slide(step, this.isSlideshowPlaying_()), function() {} /* no displayCallback */, function(loadType, delay) { // Discard, since another load has been invoked after this one. if (selectedUniqueKey != this.currentUniqueKey_) return; if (shouldPrefetch(loadType, step, this.sequenceLength_)) { this.requestPrefetch(step, delay); } if (this.isSlideshowPlaying_()) this.scheduleNextSlide_(); }.bind(this)); }.bind(this); this.metadataCache_.getOne( selectedItem.getEntry(), Gallery.METADATA_TYPE, onMetadata); }; /** * Unload the current image. * * @param {Rect} zoomToRect Rectangle for zoom effect. * @private */ SlideMode.prototype.unloadImage_ = function(zoomToRect) { this.imageView_.unload(zoomToRect); this.container_.removeAttribute('video'); }; /** * Data model 'splice' event handler. * @param {Event} event Event. * @private */ SlideMode.prototype.onSplice_ = function(event) { ImageUtil.setAttribute(this.arrowBox_, 'active', this.getItemCount_() > 1); // Splice invalidates saved indices, drop the saved selection. this.savedSelection_ = null; if (event.removed.length != 1) return; // Delay the selection to let the ribbon splice handler work first. setTimeout(function() { if (event.index < this.dataModel_.length) { // There is the next item, select it. // The next item is now at the same index as the removed one, so we need // to correct displayIndex_ so that loadSelectedItem_ does not think // we are re-selecting the same item (and does right-to-left slide-in // animation). this.displayedIndex_ = event.index - 1; this.select(event.index); } else if (this.dataModel_.length) { // Removed item is the rightmost, but there are more items. this.select(event.index - 1); // Select the new last index. } else { // No items left. Unload the image and show the banner. this.commitItem_(function() { this.unloadImage_(); this.showErrorBanner_('GALLERY_NO_IMAGES'); }.bind(this)); } }.bind(this), 0); }; /** * @param {number} direction -1 for left, 1 for right. * @return {number} Next index in the given direction, with wrapping. * @private */ SlideMode.prototype.getNextSelectedIndex_ = function(direction) { function advance(index, limit) { index += (direction > 0 ? 1 : -1); if (index < 0) return limit - 1; if (index === limit) return 0; return index; } // If the saved selection is multiple the Slideshow should cycle through // the saved selection. if (this.isSlideshowOn_() && this.savedSelection_ && this.savedSelection_.length > 1) { var pos = advance(this.savedSelection_.indexOf(this.getSelectedIndex()), this.savedSelection_.length); return this.savedSelection_[pos]; } else { return advance(this.getSelectedIndex(), this.getItemCount_()); } }; /** * Advance the selection based on the pressed key ID. * @param {string} keyID Key identifier. */ SlideMode.prototype.advanceWithKeyboard = function(keyID) { this.advanceManually(keyID === 'Up' || keyID === 'Left' ? -1 : 1); }; /** * Advance the selection as a result of a user action (as opposed to an * automatic change in the slideshow mode). * @param {number} direction -1 for left, 1 for right. */ SlideMode.prototype.advanceManually = function(direction) { if (this.isSlideshowPlaying_()) { this.pauseSlideshow_(); cr.dispatchSimpleEvent(this, 'useraction'); } this.selectNext(direction); }; /** * Select the next item. * @param {number} direction -1 for left, 1 for right. */ SlideMode.prototype.selectNext = function(direction) { this.select(this.getNextSelectedIndex_(direction), direction); }; /** * Select the first item. */ SlideMode.prototype.selectFirst = function() { this.select(0); }; /** * Select the last item. */ SlideMode.prototype.selectLast = function() { this.select(this.getItemCount_() - 1); }; // Loading/unloading /** * Load and display an item. * * @param {FileEntry} entry Item entry to be loaded. * @param {Object} metadata Item metadata. * @param {Object} effect Transition effect object. * @param {function} displayCallback Called when the image is displayed * (which can happen before the image load due to caching). * @param {function} loadCallback Called when the image is fully loaded. * @private */ SlideMode.prototype.loadItem_ = function( entry, metadata, effect, displayCallback, loadCallback) { this.selectedImageMetadata_ = MetadataCache.cloneMetadata(metadata); this.showSpinner_(true); var loadDone = function(loadType, delay, error) { var video = this.isShowingVideo_(); ImageUtil.setAttribute(this.container_, 'video', video); this.showSpinner_(false); if (loadType === ImageView.LOAD_TYPE_ERROR) { // if we have a specific error, then display it if (error) { this.showErrorBanner_(error); } else { // otherwise try to infer general error this.showErrorBanner_( video ? 'GALLERY_VIDEO_ERROR' : 'GALLERY_IMAGE_ERROR'); } } else if (loadType === ImageView.LOAD_TYPE_OFFLINE) { this.showErrorBanner_( video ? 'GALLERY_VIDEO_OFFLINE' : 'GALLERY_IMAGE_OFFLINE'); } if (video) { // The editor toolbar does not make sense for video, hide it. this.stopEditing_(); this.mediaControls_.attachMedia(this.imageView_.getVideo()); // TODO(kaznacheev): Add metrics for video playback. } else { ImageUtil.metrics.recordUserAction(ImageUtil.getMetricName('View')); var toMillions = function(number) { return Math.round(number / (1000 * 1000)); }; ImageUtil.metrics.recordSmallCount(ImageUtil.getMetricName('Size.MB'), toMillions(metadata.filesystem.size)); var canvas = this.imageView_.getCanvas(); ImageUtil.metrics.recordSmallCount(ImageUtil.getMetricName('Size.MPix'), toMillions(canvas.width * canvas.height)); var extIndex = entry.name.lastIndexOf('.'); var ext = extIndex < 0 ? '' : entry.name.substr(extIndex + 1).toLowerCase(); if (ext === 'jpeg') ext = 'jpg'; ImageUtil.metrics.recordEnum( ImageUtil.getMetricName('FileType'), ext, ImageUtil.FILE_TYPES); } // Enable or disable buttons for editing and printing. if (video || error) { this.editButton_.setAttribute('disabled', ''); this.printButton_.setAttribute('disabled', ''); } else { this.editButton_.removeAttribute('disabled'); this.printButton_.removeAttribute('disabled'); } // For once edited image, disallow the 'overwrite' setting change. ImageUtil.setAttribute(this.options_, 'saved', !this.getSelectedItem().isOriginal()); util.platform.getPreference(SlideMode.OVERWRITE_BUBBLE_KEY, function(value) { var times = typeof value === 'string' ? parseInt(value, 10) : 0; if (times < SlideMode.OVERWRITE_BUBBLE_MAX_TIMES) { this.bubble_.hidden = false; if (this.isEditing()) { util.platform.setPreference( SlideMode.OVERWRITE_BUBBLE_KEY, times + 1); } } }.bind(this)); loadCallback(loadType, delay); }.bind(this); var displayDone = function() { cr.dispatchSimpleEvent(this, 'image-displayed'); displayCallback(); }.bind(this); this.editor_.openSession(entry, metadata, effect, this.saveCurrentImage_.bind(this), displayDone, loadDone); }; /** * Commit changes to the current item and reset all messages/indicators. * * @param {function} callback Callback. * @private */ SlideMode.prototype.commitItem_ = function(callback) { this.showSpinner_(false); this.showErrorBanner_(false); this.editor_.getPrompt().hide(); // Detach any media attached to the controls. if (this.mediaControls_.getMedia()) this.mediaControls_.detachMedia(); // If showing the video, then pause it. Note, that it may not be attached // to the media controls yet. if (this.isShowingVideo_()) { this.imageView_.getVideo().pause(); // Force stop downloading, if uncached on Drive. this.imageView_.getVideo().src = ''; this.imageView_.getVideo().load(); } this.editor_.closeSession(callback); }; /** * Request a prefetch for the next image. * * @param {number} direction -1 or 1. * @param {number} delay Delay in ms. Used to prevent the CPU-heavy image * loading from disrupting the animation that might be still in progress. */ SlideMode.prototype.requestPrefetch = function(direction, delay) { if (this.getItemCount_() <= 1) return; var index = this.getNextSelectedIndex_(direction); var nextItemEntry = this.getItem(index).getEntry(); this.imageView_.prefetch(nextItemEntry, delay); }; // Event handlers. /** * Unload handler, to be called from the top frame. * @param {boolean} exiting True if the app is exiting. */ SlideMode.prototype.onUnload = function(exiting) { if (this.isShowingVideo_() && this.mediaControls_.isPlaying()) { this.mediaControls_.savePosition(exiting); } }; /** * Click handler for the image container. * * @param {Event} event Mouse click event. * @private */ SlideMode.prototype.onClick_ = function(event) { if (!this.isShowingVideo_() || !this.mediaControls_.getMedia()) return; if (event.ctrlKey) { this.mediaControls_.toggleLoopedModeWithFeedback(true); if (!this.mediaControls_.isPlaying()) this.mediaControls_.togglePlayStateWithFeedback(); } else { this.mediaControls_.togglePlayStateWithFeedback(); } }; /** * Click handler for the entire document. * @param {Event} e Mouse click event. * @private */ SlideMode.prototype.onDocumentClick_ = function(e) { // Close the bubble if clicked outside of it and if it is visible. if (!this.bubble_.contains(e.target) && !this.editButton_.contains(e.target) && !this.arrowLeft_.contains(e.target) && !this.arrowRight_.contains(e.target) && !this.bubble_.hidden) { this.bubble_.hidden = true; } }; /** * Keydown handler. * * @param {Event} event Event. * @return {boolean} True if handled. */ SlideMode.prototype.onKeyDown = function(event) { var keyID = util.getKeyModifiers(event) + event.keyIdentifier; if (this.isSlideshowOn_()) { switch (keyID) { case 'U+001B': // Escape exits the slideshow. this.stopSlideshow_(event); break; case 'U+0020': // Space pauses/resumes the slideshow. this.toggleSlideshowPause_(); break; case 'Up': case 'Down': case 'Left': case 'Right': this.advanceWithKeyboard(keyID); break; } return true; // Consume all keystrokes in the slideshow mode. } if (this.isEditing() && this.editor_.onKeyDown(event)) return true; switch (keyID) { case 'U+0020': // Space toggles the video playback. if (this.isShowingVideo_() && this.mediaControls_.getMedia()) this.mediaControls_.togglePlayStateWithFeedback(); break; case 'Ctrl-U+0050': // Ctrl+'p' prints the current image. if (!this.printButton_.hasAttribute('disabled')) this.print_(); break; case 'U+0045': // 'e' toggles the editor. if (!this.editButton_.hasAttribute('disabled')) this.toggleEditor(event); break; case 'U+001B': // Escape if (!this.isEditing()) return false; // Not handled. this.toggleEditor(event); break; case 'Home': this.selectFirst(); break; case 'End': this.selectLast(); break; case 'Up': case 'Down': case 'Left': case 'Right': this.advanceWithKeyboard(keyID); break; default: return false; } return true; }; /** * Resize handler. * @private */ SlideMode.prototype.onResize_ = function() { this.viewport_.sizeByFrameAndFit(this.container_); this.viewport_.repaint(); }; /** * Update thumbnails. */ SlideMode.prototype.updateThumbnails = function() { this.ribbon_.reset(); if (this.active_) this.ribbon_.redraw(); }; // Saving /** * Save the current image to a file. * * @param {function} callback Callback. * @private */ SlideMode.prototype.saveCurrentImage_ = function(callback) { var item = this.getSelectedItem(); var oldEntry = item.getEntry(); var canvas = this.imageView_.getCanvas(); this.showSpinner_(true); var metadataEncoder = ImageEncoder.encodeMetadata( this.selectedImageMetadata_.media, canvas, 1 /* quality */); var selectedImageMetadata = ContentProvider.ConvertContentMetadata( metadataEncoder.getMetadata(), this.selectedImageMetadata_); if (selectedImageMetadata.filesystem) selectedImageMetadata.filesystem.modificationTime = new Date(); this.selectedImageMetadata_ = selectedImageMetadata; this.metadataCache_.set(oldEntry, Gallery.METADATA_TYPE, selectedImageMetadata); item.saveToFile( this.context_.saveDirEntry, this.shouldOverwriteOriginal_(), canvas, metadataEncoder, function(success) { // TODO(kaznacheev): Implement write error handling. // Until then pretend that the save succeeded. this.showSpinner_(false); this.flashSavedLabel_(); var event = new Event('content'); event.item = item; event.oldEntry = oldEntry; event.metadata = selectedImageMetadata; this.dataModel_.dispatchEvent(event); // Allow changing the 'Overwrite original' setting only if the user // used Undo to restore the original image AND it is not a copy. // Otherwise lock the setting in its current state. var mayChangeOverwrite = !this.editor_.canUndo() && item.isOriginal(); ImageUtil.setAttribute(this.options_, 'saved', !mayChangeOverwrite); if (this.imageView_.getContentRevision() === 1) { // First edit. ImageUtil.metrics.recordUserAction(ImageUtil.getMetricName('Edit')); } if (!util.isSameEntry(oldEntry, item.getEntry())) { this.dataModel_.splice( this.getSelectedIndex(), 0, new Gallery.Item(oldEntry)); // The ribbon will ignore the splice above and redraw after the // select call below (while being obscured by the Editor toolbar, // so there is no need for nice animation here). // SlideMode will ignore the selection change as the displayed item // index has not changed. this.select(++this.displayedIndex_); } callback(); cr.dispatchSimpleEvent(this, 'image-saved'); }.bind(this)); }; /** * Update caches when the selected item has been renamed. * @param {Event} event Event. * @private */ SlideMode.prototype.onContentChange_ = function(event) { var newEntry = event.item.getEntry(); if (util.isSameEntry(newEntry, event.oldEntry)) this.imageView_.changeEntry(newEntry); this.metadataCache_.clear(event.oldEntry, Gallery.METADATA_TYPE); }; /** * Flash 'Saved' label briefly to indicate that the image has been saved. * @private */ SlideMode.prototype.flashSavedLabel_ = function() { var setLabelHighlighted = ImageUtil.setAttribute.bind(null, this.savedLabel_, 'highlighted'); setTimeout(setLabelHighlighted.bind(null, true), 0); setTimeout(setLabelHighlighted.bind(null, false), 300); }; /** * Local storage key for the 'Overwrite original' setting. * @type {string} */ SlideMode.OVERWRITE_KEY = 'gallery-overwrite-original'; /** * Local storage key for the number of times that * the overwrite info bubble has been displayed. * @type {string} */ SlideMode.OVERWRITE_BUBBLE_KEY = 'gallery-overwrite-bubble'; /** * Max number that the overwrite info bubble is shown. * @type {number} */ SlideMode.OVERWRITE_BUBBLE_MAX_TIMES = 5; /** * @return {boolean} True if 'Overwrite original' is set. * @private */ SlideMode.prototype.shouldOverwriteOriginal_ = function() { return this.overwriteOriginal_.checked; }; /** * 'Overwrite original' checkbox handler. * @param {Event} event Event. * @private */ SlideMode.prototype.onOverwriteOriginalClick_ = function(event) { util.platform.setPreference(SlideMode.OVERWRITE_KEY, event.target.checked); }; /** * Overwrite info bubble close handler. * @private */ SlideMode.prototype.onCloseBubble_ = function() { this.bubble_.hidden = true; util.platform.setPreference(SlideMode.OVERWRITE_BUBBLE_KEY, SlideMode.OVERWRITE_BUBBLE_MAX_TIMES); }; // Slideshow /** * Slideshow interval in ms. */ SlideMode.SLIDESHOW_INTERVAL = 5000; /** * First slideshow interval in ms. It should be shorter so that the user * is not guessing whether the button worked. */ SlideMode.SLIDESHOW_INTERVAL_FIRST = 1000; /** * Empirically determined duration of the fullscreen toggle animation. */ SlideMode.FULLSCREEN_TOGGLE_DELAY = 500; /** * @return {boolean} True if the slideshow is on. * @private */ SlideMode.prototype.isSlideshowOn_ = function() { return this.container_.hasAttribute('slideshow'); }; /** * Start the slideshow. * @param {number=} opt_interval First interval in ms. * @param {Event=} opt_event Event. */ SlideMode.prototype.startSlideshow = function(opt_interval, opt_event) { // Set the attribute early to prevent the toolbar from flashing when // the slideshow is being started from the mosaic view. this.container_.setAttribute('slideshow', 'playing'); if (this.active_) { this.stopEditing_(); } else { // We are in the Mosaic mode. Toggle the mode but remember to return. this.leaveAfterSlideshow_ = true; this.toggleMode_(this.startSlideshow.bind( this, SlideMode.SLIDESHOW_INTERVAL, opt_event)); return; } if (opt_event) // Caused by user action, notify the Gallery. cr.dispatchSimpleEvent(this, 'useraction'); this.fullscreenBeforeSlideshow_ = util.isFullScreen(this.context_.appWindow); if (!this.fullscreenBeforeSlideshow_) { // Wait until the zoom animation from the mosaic mode is done. setTimeout(this.toggleFullScreen_.bind(this), ImageView.ZOOM_ANIMATION_DURATION); opt_interval = (opt_interval || SlideMode.SLIDESHOW_INTERVAL) + SlideMode.FULLSCREEN_TOGGLE_DELAY; } this.resumeSlideshow_(opt_interval); }; /** * Stop the slideshow. * @param {Event=} opt_event Event. * @private */ SlideMode.prototype.stopSlideshow_ = function(opt_event) { if (!this.isSlideshowOn_()) return; if (opt_event) // Caused by user action, notify the Gallery. cr.dispatchSimpleEvent(this, 'useraction'); this.pauseSlideshow_(); this.container_.removeAttribute('slideshow'); // Do not restore fullscreen if we exited fullscreen while in slideshow. var fullscreen = util.isFullScreen(this.context_.appWindow); var toggleModeDelay = 0; if (!this.fullscreenBeforeSlideshow_ && fullscreen) { this.toggleFullScreen_(); toggleModeDelay = SlideMode.FULLSCREEN_TOGGLE_DELAY; } if (this.leaveAfterSlideshow_) { this.leaveAfterSlideshow_ = false; setTimeout(this.toggleMode_.bind(this), toggleModeDelay); } }; /** * @return {boolean} True if the slideshow is playing (not paused). * @private */ SlideMode.prototype.isSlideshowPlaying_ = function() { return this.container_.getAttribute('slideshow') === 'playing'; }; /** * Pause/resume the slideshow. * @private */ SlideMode.prototype.toggleSlideshowPause_ = function() { cr.dispatchSimpleEvent(this, 'useraction'); // Show the tools. if (this.isSlideshowPlaying_()) { this.pauseSlideshow_(); } else { this.resumeSlideshow_(SlideMode.SLIDESHOW_INTERVAL_FIRST); } }; /** * @param {number=} opt_interval Slideshow interval in ms. * @private */ SlideMode.prototype.scheduleNextSlide_ = function(opt_interval) { console.assert(this.isSlideshowPlaying_(), 'Inconsistent slideshow state'); if (this.slideShowTimeout_) clearTimeout(this.slideShowTimeout_); this.slideShowTimeout_ = setTimeout(function() { this.slideShowTimeout_ = null; this.selectNext(1); }.bind(this), opt_interval || SlideMode.SLIDESHOW_INTERVAL); }; /** * Resume the slideshow. * @param {number=} opt_interval Slideshow interval in ms. * @private */ SlideMode.prototype.resumeSlideshow_ = function(opt_interval) { this.container_.setAttribute('slideshow', 'playing'); this.scheduleNextSlide_(opt_interval); }; /** * Pause the slideshow. * @private */ SlideMode.prototype.pauseSlideshow_ = function() { this.container_.setAttribute('slideshow', 'paused'); if (this.slideShowTimeout_) { clearTimeout(this.slideShowTimeout_); this.slideShowTimeout_ = null; } }; /** * @return {boolean} True if the editor is active. */ SlideMode.prototype.isEditing = function() { return this.container_.hasAttribute('editing'); }; /** * Stop editing. * @private */ SlideMode.prototype.stopEditing_ = function() { if (this.isEditing()) this.toggleEditor(); }; /** * Activate/deactivate editor. * @param {Event=} opt_event Event. */ SlideMode.prototype.toggleEditor = function(opt_event) { if (opt_event) // Caused by user action, notify the Gallery. cr.dispatchSimpleEvent(this, 'useraction'); if (!this.active_) { this.toggleMode_(this.toggleEditor.bind(this)); return; } this.stopSlideshow_(); if (!this.isEditing() && this.isShowingVideo_()) return; // No editing for videos. ImageUtil.setAttribute(this.container_, 'editing', !this.isEditing()); if (this.isEditing()) { // isEditing has just been flipped to a new value. if (this.context_.readonlyDirName) { this.editor_.getPrompt().showAt( 'top', 'GALLERY_READONLY_WARNING', 0, this.context_.readonlyDirName); } } else { this.editor_.getPrompt().hide(); this.editor_.leaveModeGently(); } }; /** * Prints the current item. * @private */ SlideMode.prototype.print_ = function() { cr.dispatchSimpleEvent(this, 'useraction'); window.print(); }; /** * Display the error banner. * @param {string} message Message. * @private */ SlideMode.prototype.showErrorBanner_ = function(message) { if (message) { this.errorBanner_.textContent = this.displayStringFunction_(message); } ImageUtil.setAttribute(this.container_, 'error', !!message); }; /** * Show/hide the busy spinner. * * @param {boolean} on True if show, false if hide. * @private */ SlideMode.prototype.showSpinner_ = function(on) { if (this.spinnerTimer_) { clearTimeout(this.spinnerTimer_); this.spinnerTimer_ = null; } if (on) { this.spinnerTimer_ = setTimeout(function() { this.spinnerTimer_ = null; ImageUtil.setAttribute(this.container_, 'spinner', true); }.bind(this), 1000); } else { ImageUtil.setAttribute(this.container_, 'spinner', false); } }; /** * @return {boolean} True if the current item is a video. * @private */ SlideMode.prototype.isShowingVideo_ = function() { return !!this.imageView_.getVideo(); }; /** * Overlay that handles swipe gestures. Changes to the next or previous file. * @param {function(number)} callback A callback accepting the swipe direction * (1 means left, -1 right). * @constructor * @implements {ImageBuffer.Overlay} */ function SwipeOverlay(callback) { this.callback_ = callback; } /** * Inherit ImageBuffer.Overlay. */ SwipeOverlay.prototype.__proto__ = ImageBuffer.Overlay.prototype; /** * @param {number} x X pointer position. * @param {number} y Y pointer position. * @param {boolean} touch True if dragging caused by touch. * @return {function} The closure to call on drag. */ SwipeOverlay.prototype.getDragHandler = function(x, y, touch) { if (!touch) return null; var origin = x; var done = false; return function(x, y) { if (!done && origin - x > SwipeOverlay.SWIPE_THRESHOLD) { this.callback_(1); done = true; } else if (!done && x - origin > SwipeOverlay.SWIPE_THRESHOLD) { this.callback_(-1); done = true; } }.bind(this); }; /** * If the user touched the image and moved the finger more than SWIPE_THRESHOLD * horizontally it's considered as a swipe gesture (change the current image). */ SwipeOverlay.SWIPE_THRESHOLD = 100;