1 // Copyright (c) 2012 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 * TODO(kaznacheev): Introduce a parameter object. 12 * 13 * @param {Element} container Main container element. 14 * @param {Element} content Content container element. 15 * @param {Element} toolbar Toolbar element. 16 * @param {ImageEditor.Prompt} prompt Prompt. 17 * @param {cr.ui.ArrayDataModel} dataModel Data model. 18 * @param {cr.ui.ListSelectionModel} selectionModel Selection model. 19 * @param {Object} context Context. 20 * @param {function(function())} toggleMode Function to toggle the Gallery mode. 21 * @param {function(string):string} displayStringFunction String formatting 22 * function. 23 * @constructor 24 */ 25 function SlideMode(container, content, toolbar, prompt, 26 dataModel, selectionModel, context, 27 toggleMode, displayStringFunction) { 28 this.container_ = container; 29 this.document_ = container.ownerDocument; 30 this.content = content; 31 this.toolbar_ = toolbar; 32 this.prompt_ = prompt; 33 this.dataModel_ = dataModel; 34 this.selectionModel_ = selectionModel; 35 this.context_ = context; 36 this.metadataCache_ = context.metadataCache; 37 this.toggleMode_ = toggleMode; 38 this.displayStringFunction_ = displayStringFunction; 39 40 this.onSelectionBound_ = this.onSelection_.bind(this); 41 this.onSpliceBound_ = this.onSplice_.bind(this); 42 this.onContentBound_ = this.onContentChange_.bind(this); 43 44 // Unique numeric key, incremented per each load attempt used to discard 45 // old attempts. This can happen especially when changing selection fast or 46 // Internet connection is slow. 47 this.currentUniqueKey_ = 0; 48 49 this.initListeners_(); 50 this.initDom_(); 51 } 52 53 /** 54 * SlideMode extends cr.EventTarget. 55 */ 56 SlideMode.prototype.__proto__ = cr.EventTarget.prototype; 57 58 /** 59 * List of available editor modes. 60 * @type {Array.<ImageEditor.Mode>} 61 */ 62 SlideMode.editorModes = [ 63 new ImageEditor.Mode.InstantAutofix(), 64 new ImageEditor.Mode.Crop(), 65 new ImageEditor.Mode.Exposure(), 66 new ImageEditor.Mode.OneClick( 67 'rotate_left', 'GALLERY_ROTATE_LEFT', new Command.Rotate(-1)), 68 new ImageEditor.Mode.OneClick( 69 'rotate_right', 'GALLERY_ROTATE_RIGHT', new Command.Rotate(1)) 70 ]; 71 72 /** 73 * @return {string} Mode name. 74 */ 75 SlideMode.prototype.getName = function() { return 'slide' }; 76 77 /** 78 * @return {string} Mode title. 79 */ 80 SlideMode.prototype.getTitle = function() { return 'GALLERY_SLIDE' }; 81 82 /** 83 * Initialize the listeners. 84 * @private 85 */ 86 SlideMode.prototype.initListeners_ = function() { 87 window.addEventListener('resize', this.onResize_.bind(this), false); 88 }; 89 90 /** 91 * Initialize the UI. 92 * @private 93 */ 94 SlideMode.prototype.initDom_ = function() { 95 // Container for displayed image or video. 96 this.imageContainer_ = util.createChild( 97 this.document_.querySelector('.content'), 'image-container'); 98 this.imageContainer_.addEventListener('click', this.onClick_.bind(this)); 99 100 this.document_.addEventListener('click', this.onDocumentClick_.bind(this)); 101 102 // Overwrite options and info bubble. 103 this.options_ = util.createChild( 104 this.toolbar_.querySelector('.filename-spacer'), 'options'); 105 106 this.savedLabel_ = util.createChild(this.options_, 'saved'); 107 this.savedLabel_.textContent = this.displayStringFunction_('GALLERY_SAVED'); 108 109 var overwriteOriginalBox = 110 util.createChild(this.options_, 'overwrite-original'); 111 112 this.overwriteOriginal_ = util.createChild( 113 overwriteOriginalBox, 'common white', 'input'); 114 this.overwriteOriginal_.type = 'checkbox'; 115 this.overwriteOriginal_.id = 'overwrite-checkbox'; 116 util.platform.getPreference(SlideMode.OVERWRITE_KEY, function(value) { 117 // Out-of-the box default is 'true' 118 this.overwriteOriginal_.checked = 119 (typeof value != 'string' || value == 'true'); 120 }.bind(this)); 121 this.overwriteOriginal_.addEventListener('click', 122 this.onOverwriteOriginalClick_.bind(this)); 123 124 var overwriteLabel = util.createChild(overwriteOriginalBox, '', 'label'); 125 overwriteLabel.textContent = 126 this.displayStringFunction_('GALLERY_OVERWRITE_ORIGINAL'); 127 overwriteLabel.setAttribute('for', 'overwrite-checkbox'); 128 129 this.bubble_ = util.createChild(this.toolbar_, 'bubble'); 130 this.bubble_.hidden = true; 131 132 var bubbleContent = util.createChild(this.bubble_); 133 bubbleContent.innerHTML = this.displayStringFunction_( 134 'GALLERY_OVERWRITE_BUBBLE'); 135 136 util.createChild(this.bubble_, 'pointer bottom', 'span'); 137 138 var bubbleClose = util.createChild(this.bubble_, 'close-x'); 139 bubbleClose.addEventListener('click', this.onCloseBubble_.bind(this)); 140 141 // Video player controls. 142 this.mediaSpacer_ = 143 util.createChild(this.container_, 'video-controls-spacer'); 144 this.mediaToolbar_ = util.createChild(this.mediaSpacer_, 'tool'); 145 this.mediaControls_ = new VideoControls( 146 this.mediaToolbar_, 147 this.showErrorBanner_.bind(this, 'GALLERY_VIDEO_ERROR'), 148 this.displayStringFunction_.bind(this), 149 this.toggleFullScreen_.bind(this), 150 this.container_); 151 152 // Ribbon and related controls. 153 this.arrowBox_ = util.createChild(this.container_, 'arrow-box'); 154 155 this.arrowLeft_ = 156 util.createChild(this.arrowBox_, 'arrow left tool dimmable'); 157 this.arrowLeft_.addEventListener('click', 158 this.advanceManually.bind(this, -1)); 159 util.createChild(this.arrowLeft_); 160 161 util.createChild(this.arrowBox_, 'arrow-spacer'); 162 163 this.arrowRight_ = 164 util.createChild(this.arrowBox_, 'arrow right tool dimmable'); 165 this.arrowRight_.addEventListener('click', 166 this.advanceManually.bind(this, 1)); 167 util.createChild(this.arrowRight_); 168 169 this.ribbonSpacer_ = util.createChild(this.toolbar_, 'ribbon-spacer'); 170 this.ribbon_ = new Ribbon(this.document_, 171 this.metadataCache_, this.dataModel_, this.selectionModel_); 172 this.ribbonSpacer_.appendChild(this.ribbon_); 173 174 // Error indicator. 175 var errorWrapper = util.createChild(this.container_, 'prompt-wrapper'); 176 errorWrapper.setAttribute('pos', 'center'); 177 178 this.errorBanner_ = util.createChild(errorWrapper, 'error-banner'); 179 180 util.createChild(this.container_, 'spinner'); 181 182 var slideShowButton = util.createChild(this.toolbar_, 183 'button slideshow', 'button'); 184 slideShowButton.title = this.displayStringFunction_('GALLERY_SLIDESHOW'); 185 slideShowButton.addEventListener('click', 186 this.startSlideshow.bind(this, SlideMode.SLIDESHOW_INTERVAL_FIRST)); 187 188 var slideShowToolbar = 189 util.createChild(this.container_, 'tool slideshow-toolbar'); 190 util.createChild(slideShowToolbar, 'slideshow-play'). 191 addEventListener('click', this.toggleSlideshowPause_.bind(this)); 192 util.createChild(slideShowToolbar, 'slideshow-end'). 193 addEventListener('click', this.stopSlideshow_.bind(this)); 194 195 // Editor. 196 197 this.editButton_ = util.createChild(this.toolbar_, 'button edit', 'button'); 198 this.editButton_.title = this.displayStringFunction_('GALLERY_EDIT'); 199 this.editButton_.addEventListener('click', this.toggleEditor.bind(this)); 200 201 this.editBarSpacer_ = util.createChild(this.toolbar_, 'edit-bar-spacer'); 202 this.editBarMain_ = util.createChild(this.editBarSpacer_, 'edit-main'); 203 204 this.editBarMode_ = util.createChild(this.container_, 'edit-modal'); 205 this.editBarModeWrapper_ = util.createChild( 206 this.editBarMode_, 'edit-modal-wrapper'); 207 this.editBarModeWrapper_.hidden = true; 208 209 // Objects supporting image display and editing. 210 this.viewport_ = new Viewport(); 211 212 this.imageView_ = new ImageView( 213 this.imageContainer_, 214 this.viewport_, 215 this.metadataCache_); 216 217 this.editor_ = new ImageEditor( 218 this.viewport_, 219 this.imageView_, 220 this.prompt_, 221 { 222 root: this.container_, 223 image: this.imageContainer_, 224 toolbar: this.editBarMain_, 225 mode: this.editBarModeWrapper_ 226 }, 227 SlideMode.editorModes, 228 this.displayStringFunction_); 229 230 this.editor_.getBuffer().addOverlay( 231 new SwipeOverlay(this.advanceManually.bind(this))); 232 }; 233 234 /** 235 * Load items, display the selected item. 236 * @param {Rect} zoomFromRect Rectangle for zoom effect. 237 * @param {function} displayCallback Called when the image is displayed. 238 * @param {function} loadCallback Called when the image is displayed. 239 */ 240 SlideMode.prototype.enter = function( 241 zoomFromRect, displayCallback, loadCallback) { 242 this.sequenceDirection_ = 0; 243 this.sequenceLength_ = 0; 244 245 var loadDone = function(loadType, delay) { 246 this.active_ = true; 247 248 this.selectionModel_.addEventListener('change', this.onSelectionBound_); 249 this.dataModel_.addEventListener('splice', this.onSpliceBound_); 250 this.dataModel_.addEventListener('content', this.onContentBound_); 251 252 ImageUtil.setAttribute(this.arrowBox_, 'active', this.getItemCount_() > 1); 253 this.ribbon_.enable(); 254 255 // Wait 1000ms after the animation is done, then prefetch the next image. 256 this.requestPrefetch(1, delay + 1000); 257 258 if (loadCallback) loadCallback(); 259 }.bind(this); 260 261 // The latest |leave| call might have left the image animating. Remove it. 262 this.unloadImage_(); 263 264 if (this.getItemCount_() == 0) { 265 this.displayedIndex_ = -1; 266 //TODO(kaznacheev) Show this message in the grid mode too. 267 this.showErrorBanner_('GALLERY_NO_IMAGES'); 268 loadDone(); 269 } else { 270 // Remember the selection if it is empty or multiple. It will be restored 271 // in |leave| if the user did not changing the selection manually. 272 var currentSelection = this.selectionModel_.selectedIndexes; 273 if (currentSelection.length == 1) 274 this.savedSelection_ = null; 275 else 276 this.savedSelection_ = currentSelection; 277 278 // Ensure valid single selection. 279 // Note that the SlideMode object is not listening to selection change yet. 280 this.select(Math.max(0, this.getSelectedIndex())); 281 this.displayedIndex_ = this.getSelectedIndex(); 282 283 var selectedItem = this.getSelectedItem(); 284 var selectedUrl = selectedItem.getUrl(); 285 // Show the selected item ASAP, then complete the initialization 286 // (loading the ribbon thumbnails can take some time). 287 this.metadataCache_.get(selectedUrl, Gallery.METADATA_TYPE, 288 function(metadata) { 289 this.loadItem_(selectedUrl, metadata, 290 zoomFromRect && this.imageView_.createZoomEffect(zoomFromRect), 291 displayCallback, loadDone); 292 }.bind(this)); 293 294 } 295 }; 296 297 /** 298 * Leave the mode. 299 * @param {Rect} zoomToRect Rectangle for zoom effect. 300 * @param {function} callback Called when the image is committed and 301 * the zoom-out animation has started. 302 */ 303 SlideMode.prototype.leave = function(zoomToRect, callback) { 304 var commitDone = function() { 305 this.stopEditing_(); 306 this.stopSlideshow_(); 307 ImageUtil.setAttribute(this.arrowBox_, 'active', false); 308 this.selectionModel_.removeEventListener( 309 'change', this.onSelectionBound_); 310 this.dataModel_.removeEventListener('splice', this.onSpliceBound_); 311 this.dataModel_.removeEventListener('content', this.onContentBound_); 312 this.ribbon_.disable(); 313 this.active_ = false; 314 if (this.savedSelection_) 315 this.selectionModel_.selectedIndexes = this.savedSelection_; 316 this.unloadImage_(zoomToRect); 317 callback(); 318 }.bind(this); 319 320 if (this.getItemCount_() == 0) { 321 this.showErrorBanner_(false); 322 commitDone(); 323 } else { 324 this.commitItem_(commitDone); 325 } 326 }; 327 328 329 /** 330 * Execute an action when the editor is not busy. 331 * 332 * @param {function} action Function to exectute. 333 */ 334 SlideMode.prototype.executeWhenReady = function(action) { 335 this.editor_.executeWhenReady(action); 336 }; 337 338 /** 339 * @return {boolean} True if the mode has active tools (that should not fade). 340 */ 341 SlideMode.prototype.hasActiveTool = function() { 342 return this.isEditing(); 343 }; 344 345 /** 346 * @return {number} Item count. 347 * @private 348 */ 349 SlideMode.prototype.getItemCount_ = function() { 350 return this.dataModel_.length; 351 }; 352 353 /** 354 * @param {number} index Index. 355 * @return {Gallery.Item} Item. 356 */ 357 SlideMode.prototype.getItem = function(index) { 358 return this.dataModel_.item(index); 359 }; 360 361 /** 362 * @return {Gallery.Item} Selected index. 363 */ 364 SlideMode.prototype.getSelectedIndex = function() { 365 return this.selectionModel_.selectedIndex; 366 }; 367 368 /** 369 * @return {Rect} Screen rectangle of the selected image. 370 */ 371 SlideMode.prototype.getSelectedImageRect = function() { 372 if (this.getSelectedIndex() < 0) 373 return null; 374 else 375 return this.viewport_.getScreenClipped(); 376 }; 377 378 /** 379 * @return {Gallery.Item} Selected item. 380 */ 381 SlideMode.prototype.getSelectedItem = function() { 382 return this.getItem(this.getSelectedIndex()); 383 }; 384 385 /** 386 * Toggles the full screen mode. 387 * @private 388 */ 389 SlideMode.prototype.toggleFullScreen_ = function() { 390 util.toggleFullScreen(this.context_.appWindow, 391 !util.isFullScreen(this.context_.appWindow)); 392 }; 393 394 /** 395 * Selection change handler. 396 * 397 * Commits the current image and displays the newly selected image. 398 * @private 399 */ 400 SlideMode.prototype.onSelection_ = function() { 401 if (this.selectionModel_.selectedIndexes.length == 0) 402 return; // Temporary empty selection. 403 404 // Forget the saved selection if the user changed the selection manually. 405 if (!this.isSlideshowOn_()) 406 this.savedSelection_ = null; 407 408 if (this.getSelectedIndex() == this.displayedIndex_) 409 return; // Do not reselect. 410 411 this.commitItem_(this.loadSelectedItem_.bind(this)); 412 }; 413 414 /** 415 * Change the selection. 416 * 417 * @param {number} index New selected index. 418 * @param {number=} opt_slideHint Slide animation direction (-1|1). 419 */ 420 SlideMode.prototype.select = function(index, opt_slideHint) { 421 this.slideHint_ = opt_slideHint; 422 this.selectionModel_.selectedIndex = index; 423 this.selectionModel_.leadIndex = index; 424 }; 425 426 /** 427 * Load the selected item. 428 * 429 * @private 430 */ 431 SlideMode.prototype.loadSelectedItem_ = function() { 432 var slideHint = this.slideHint_; 433 this.slideHint_ = undefined; 434 435 var index = this.getSelectedIndex(); 436 if (index == this.displayedIndex_) 437 return; // Do not reselect. 438 439 var step = slideHint || (index - this.displayedIndex_); 440 441 if (Math.abs(step) != 1) { 442 // Long leap, the sequence is broken, we have no good prefetch candidate. 443 this.sequenceDirection_ = 0; 444 this.sequenceLength_ = 0; 445 } else if (this.sequenceDirection_ == step) { 446 // Keeping going in sequence. 447 this.sequenceLength_++; 448 } else { 449 // Reversed the direction. Reset the counter. 450 this.sequenceDirection_ = step; 451 this.sequenceLength_ = 1; 452 } 453 454 if (this.sequenceLength_ <= 1) { 455 // We have just broke the sequence. Touch the current image so that it stays 456 // in the cache longer. 457 this.imageView_.prefetch(this.imageView_.contentID_); 458 } 459 460 this.displayedIndex_ = index; 461 462 function shouldPrefetch(loadType, step, sequenceLength) { 463 // Never prefetch when selecting out of sequence. 464 if (Math.abs(step) != 1) 465 return false; 466 467 // Never prefetch after a video load (decoding the next image can freeze 468 // the UI for a second or two). 469 if (loadType == ImageView.LOAD_TYPE_VIDEO_FILE) 470 return false; 471 472 // Always prefetch if the previous load was from cache. 473 if (loadType == ImageView.LOAD_TYPE_CACHED_FULL) 474 return true; 475 476 // Prefetch if we have been going in the same direction for long enough. 477 return sequenceLength >= 3; 478 } 479 480 var selectedItem = this.getSelectedItem(); 481 this.currentUniqueKey_++; 482 var selectedUniqueKey = this.currentUniqueKey_; 483 var onMetadata = function(metadata) { 484 // Discard, since another load has been invoked after this one. 485 if (selectedUniqueKey != this.currentUniqueKey_) return; 486 this.loadItem_(selectedItem.getUrl(), metadata, 487 new ImageView.Effect.Slide(step, this.isSlideshowPlaying_()), 488 function() {} /* no displayCallback */, 489 function(loadType, delay) { 490 // Discard, since another load has been invoked after this one. 491 if (selectedUniqueKey != this.currentUniqueKey_) return; 492 if (shouldPrefetch(loadType, step, this.sequenceLength_)) { 493 this.requestPrefetch(step, delay); 494 } 495 if (this.isSlideshowPlaying_()) 496 this.scheduleNextSlide_(); 497 }.bind(this)); 498 }.bind(this); 499 this.metadataCache_.get( 500 selectedItem.getUrl(), Gallery.METADATA_TYPE, onMetadata); 501 }; 502 503 /** 504 * Unload the current image. 505 * 506 * @param {Rect} zoomToRect Rectangle for zoom effect. 507 * @private 508 */ 509 SlideMode.prototype.unloadImage_ = function(zoomToRect) { 510 this.imageView_.unload(zoomToRect); 511 this.container_.removeAttribute('video'); 512 }; 513 514 /** 515 * Data model 'splice' event handler. 516 * @param {Event} event Event. 517 * @private 518 */ 519 SlideMode.prototype.onSplice_ = function(event) { 520 ImageUtil.setAttribute(this.arrowBox_, 'active', this.getItemCount_() > 1); 521 522 // Splice invalidates saved indices, drop the saved selection. 523 this.savedSelection_ = null; 524 525 if (event.removed.length != 1) 526 return; 527 528 // Delay the selection to let the ribbon splice handler work first. 529 setTimeout(function() { 530 if (event.index < this.dataModel_.length) { 531 // There is the next item, select it. 532 // The next item is now at the same index as the removed one, so we need 533 // to correct displayIndex_ so that loadSelectedItem_ does not think 534 // we are re-selecting the same item (and does right-to-left slide-in 535 // animation). 536 this.displayedIndex_ = event.index - 1; 537 this.select(event.index); 538 } else if (this.dataModel_.length) { 539 // Removed item is the rightmost, but there are more items. 540 this.select(event.index - 1); // Select the new last index. 541 } else { 542 // No items left. Unload the image and show the banner. 543 this.commitItem_(function() { 544 this.unloadImage_(); 545 this.showErrorBanner_('GALLERY_NO_IMAGES'); 546 }.bind(this)); 547 } 548 }.bind(this), 0); 549 }; 550 551 /** 552 * @param {number} direction -1 for left, 1 for right. 553 * @return {number} Next index in the gived direction, with wrapping. 554 * @private 555 */ 556 SlideMode.prototype.getNextSelectedIndex_ = function(direction) { 557 function advance(index, limit) { 558 index += (direction > 0 ? 1 : -1); 559 if (index < 0) 560 return limit - 1; 561 if (index == limit) 562 return 0; 563 return index; 564 } 565 566 // If the saved selection is multiple the Slideshow should cycle through 567 // the saved selection. 568 if (this.isSlideshowOn_() && 569 this.savedSelection_ && this.savedSelection_.length > 1) { 570 var pos = advance(this.savedSelection_.indexOf(this.getSelectedIndex()), 571 this.savedSelection_.length); 572 return this.savedSelection_[pos]; 573 } else { 574 return advance(this.getSelectedIndex(), this.getItemCount_()); 575 } 576 }; 577 578 /** 579 * Advance the selection based on the pressed key ID. 580 * @param {string} keyID Key identifier. 581 */ 582 SlideMode.prototype.advanceWithKeyboard = function(keyID) { 583 this.advanceManually(keyID == 'Up' || keyID == 'Left' ? -1 : 1); 584 }; 585 586 /** 587 * Advance the selection as a result of a user action (as opposed to an 588 * automatic change in the slideshow mode). 589 * @param {number} direction -1 for left, 1 for right. 590 */ 591 SlideMode.prototype.advanceManually = function(direction) { 592 if (this.isSlideshowPlaying_()) { 593 this.pauseSlideshow_(); 594 cr.dispatchSimpleEvent(this, 'useraction'); 595 } 596 this.selectNext(direction); 597 }; 598 599 /** 600 * Select the next item. 601 * @param {number} direction -1 for left, 1 for right. 602 */ 603 SlideMode.prototype.selectNext = function(direction) { 604 this.select(this.getNextSelectedIndex_(direction), direction); 605 }; 606 607 /** 608 * Select the first item. 609 */ 610 SlideMode.prototype.selectFirst = function() { 611 this.select(0); 612 }; 613 614 /** 615 * Select the last item. 616 */ 617 SlideMode.prototype.selectLast = function() { 618 this.select(this.getItemCount_() - 1); 619 }; 620 621 // Loading/unloading 622 623 /** 624 * Load and display an item. 625 * 626 * @param {string} url Item url. 627 * @param {Object} metadata Item metadata. 628 * @param {Object} effect Transition effect object. 629 * @param {function} displayCallback Called when the image is displayed 630 * (which can happen before the image load due to caching). 631 * @param {function} loadCallback Called when the image is fully loaded. 632 * @private 633 */ 634 SlideMode.prototype.loadItem_ = function( 635 url, metadata, effect, displayCallback, loadCallback) { 636 this.selectedImageMetadata_ = MetadataCache.cloneMetadata(metadata); 637 638 this.showSpinner_(true); 639 640 var loadDone = function(loadType, delay, error) { 641 var video = this.isShowingVideo_(); 642 ImageUtil.setAttribute(this.container_, 'video', video); 643 644 this.showSpinner_(false); 645 if (loadType == ImageView.LOAD_TYPE_ERROR) { 646 // if we have a specific error, then display it 647 if (error) { 648 this.showErrorBanner_('GALLERY_' + error); 649 } else { 650 // otherwise try to infer general error 651 this.showErrorBanner_( 652 video ? 'GALLERY_VIDEO_ERROR' : 'GALLERY_IMAGE_ERROR'); 653 } 654 } else if (loadType == ImageView.LOAD_TYPE_OFFLINE) { 655 this.showErrorBanner_( 656 video ? 'GALLERY_VIDEO_OFFLINE' : 'GALLERY_IMAGE_OFFLINE'); 657 } 658 659 if (video) { 660 // The editor toolbar does not make sense for video, hide it. 661 this.stopEditing_(); 662 this.mediaControls_.attachMedia(this.imageView_.getVideo()); 663 //TODO(kaznacheev): Add metrics for video playback. 664 } else { 665 ImageUtil.metrics.recordUserAction(ImageUtil.getMetricName('View')); 666 667 var toMillions = function(number) { 668 return Math.round(number / (1000 * 1000)); 669 }; 670 671 ImageUtil.metrics.recordSmallCount(ImageUtil.getMetricName('Size.MB'), 672 toMillions(metadata.filesystem.size)); 673 674 var canvas = this.imageView_.getCanvas(); 675 ImageUtil.metrics.recordSmallCount(ImageUtil.getMetricName('Size.MPix'), 676 toMillions(canvas.width * canvas.height)); 677 678 var extIndex = url.lastIndexOf('.'); 679 var ext = extIndex < 0 ? '' : url.substr(extIndex + 1).toLowerCase(); 680 if (ext == 'jpeg') ext = 'jpg'; 681 ImageUtil.metrics.recordEnum( 682 ImageUtil.getMetricName('FileType'), ext, ImageUtil.FILE_TYPES); 683 } 684 685 // For once edited image, disallow the 'overwrite' setting change. 686 ImageUtil.setAttribute(this.options_, 'saved', 687 !this.getSelectedItem().isOriginal()); 688 689 util.platform.getPreference(SlideMode.OVERWRITE_BUBBLE_KEY, 690 function(value) { 691 var times = typeof value == 'string' ? parseInt(value, 10) : 0; 692 if (times < SlideMode.OVERWRITE_BUBBLE_MAX_TIMES) { 693 this.bubble_.hidden = false; 694 if (this.isEditing()) { 695 util.platform.setPreference( 696 SlideMode.OVERWRITE_BUBBLE_KEY, times + 1); 697 } 698 } 699 }.bind(this)); 700 701 loadCallback(loadType, delay); 702 }.bind(this); 703 704 var displayDone = function() { 705 cr.dispatchSimpleEvent(this, 'image-displayed'); 706 displayCallback(); 707 }.bind(this); 708 709 this.editor_.openSession(url, metadata, effect, 710 this.saveCurrentImage_.bind(this), displayDone, loadDone); 711 }; 712 713 /** 714 * Commit changes to the current item and reset all messages/indicators. 715 * 716 * @param {function} callback Callback. 717 * @private 718 */ 719 SlideMode.prototype.commitItem_ = function(callback) { 720 this.showSpinner_(false); 721 this.showErrorBanner_(false); 722 this.editor_.getPrompt().hide(); 723 724 // Detach any media attached to the controls. 725 if (this.mediaControls_.getMedia()) 726 this.mediaControls_.detachMedia(); 727 728 // If showing the video, then pause it. Note, that it may not be attached 729 // to the media controls yet. 730 if (this.isShowingVideo_()) 731 this.imageView_.getVideo().pause(); 732 733 this.editor_.closeSession(callback); 734 }; 735 736 /** 737 * Request a prefetch for the next image. 738 * 739 * @param {number} direction -1 or 1. 740 * @param {number} delay Delay in ms. Used to prevent the CPU-heavy image 741 * loading from disrupting the animation that might be still in progress. 742 */ 743 SlideMode.prototype.requestPrefetch = function(direction, delay) { 744 if (this.getItemCount_() <= 1) return; 745 746 var index = this.getNextSelectedIndex_(direction); 747 var nextItemUrl = this.getItem(index).getUrl(); 748 this.imageView_.prefetch(nextItemUrl, delay); 749 }; 750 751 // Event handlers. 752 753 /** 754 * Unload handler, to be called from the top frame. 755 * @param {boolean} exiting True if the app is exiting. 756 */ 757 SlideMode.prototype.onUnload = function(exiting) { 758 if (this.isShowingVideo_() && this.mediaControls_.isPlaying()) { 759 this.mediaControls_.savePosition(exiting); 760 } 761 }; 762 763 /** 764 * beforeunload handler, to be called from the top frame. 765 * @return {string} Message to show if there are unsaved changes. 766 */ 767 SlideMode.prototype.onBeforeUnload = function() { 768 if (this.editor_.isBusy()) 769 return this.displayStringFunction_('GALLERY_UNSAVED_CHANGES'); 770 return null; 771 }; 772 773 /** 774 * Click handler for the image container. 775 * 776 * @param {Event} event Mouse click event. 777 * @private 778 */ 779 SlideMode.prototype.onClick_ = function(event) { 780 if (!this.isShowingVideo_()) 781 return; 782 if (event.ctrlKey) { 783 this.mediaControls_.toggleLoopedModeWithFeedback(true); 784 if (!this.mediaControls_.isPlaying()) 785 this.mediaControls_.togglePlayStateWithFeedback(); 786 } else { 787 this.mediaControls_.togglePlayStateWithFeedback(); 788 } 789 }; 790 791 /** 792 * Click handler for the entire document. 793 * @param {Event} e Mouse click event. 794 * @private 795 */ 796 SlideMode.prototype.onDocumentClick_ = function(e) { 797 // Close the bubble if clicked outside of it and if it is visible. 798 if (!this.bubble_.contains(e.target) && 799 !this.editButton_.contains(e.target) && 800 !this.arrowLeft_.contains(e.target) && 801 !this.arrowRight_.contains(e.target) && 802 !this.bubble_.hidden) { 803 this.bubble_.hidden = true; 804 } 805 }; 806 807 /** 808 * Keydown handler. 809 * 810 * @param {Event} event Event. 811 * @return {boolean} True if handled. 812 */ 813 SlideMode.prototype.onKeyDown = function(event) { 814 var keyID = util.getKeyModifiers(event) + event.keyIdentifier; 815 816 if (this.isSlideshowOn_()) { 817 switch (keyID) { 818 case 'U+001B': // Escape exits the slideshow. 819 this.stopSlideshow_(event); 820 break; 821 822 case 'U+0020': // Space pauses/resumes the slideshow. 823 this.toggleSlideshowPause_(); 824 break; 825 826 case 'Up': 827 case 'Down': 828 case 'Left': 829 case 'Right': 830 this.advanceWithKeyboard(keyID); 831 break; 832 } 833 return true; // Consume all keystrokes in the slideshow mode. 834 } 835 836 if (this.isEditing() && this.editor_.onKeyDown(event)) 837 return true; 838 839 switch (keyID) { 840 case 'U+0020': // Space toggles the video playback. 841 if (this.isShowingVideo_()) { 842 this.mediaControls_.togglePlayStateWithFeedback(); 843 } 844 break; 845 846 case 'U+0045': // 'e' toggles the editor. 847 this.toggleEditor(event); 848 break; 849 850 case 'U+001B': // Escape 851 if (!this.isEditing()) 852 return false; // Not handled. 853 this.toggleEditor(event); 854 break; 855 856 case 'Home': 857 this.selectFirst(); 858 break; 859 case 'End': 860 this.selectLast(); 861 break; 862 case 'Up': 863 case 'Down': 864 case 'Left': 865 case 'Right': 866 this.advanceWithKeyboard(keyID); 867 break; 868 869 default: return false; 870 } 871 872 return true; 873 }; 874 875 /** 876 * Resize handler. 877 * @private 878 */ 879 SlideMode.prototype.onResize_ = function() { 880 this.viewport_.sizeByFrameAndFit(this.container_); 881 this.viewport_.repaint(); 882 }; 883 884 /** 885 * Update thumbnails. 886 */ 887 SlideMode.prototype.updateThumbnails = function() { 888 this.ribbon_.reset(); 889 if (this.active_) 890 this.ribbon_.redraw(); 891 }; 892 893 // Saving 894 895 /** 896 * Save the current image to a file. 897 * 898 * @param {function} callback Callback. 899 * @private 900 */ 901 SlideMode.prototype.saveCurrentImage_ = function(callback) { 902 var item = this.getSelectedItem(); 903 var oldUrl = item.getUrl(); 904 var canvas = this.imageView_.getCanvas(); 905 906 this.showSpinner_(true); 907 var metadataEncoder = ImageEncoder.encodeMetadata( 908 this.selectedImageMetadata_.media, canvas, 1 /* quality */); 909 910 this.selectedImageMetadata_ = ContentProvider.ConvertContentMetadata( 911 metadataEncoder.getMetadata(), this.selectedImageMetadata_); 912 913 item.saveToFile( 914 this.context_.saveDirEntry, 915 this.shouldOverwriteOriginal_(), 916 canvas, 917 metadataEncoder, 918 function(success) { 919 // TODO(kaznacheev): Implement write error handling. 920 // Until then pretend that the save succeeded. 921 this.showSpinner_(false); 922 this.flashSavedLabel_(); 923 924 var e = new cr.Event('content'); 925 e.item = item; 926 e.oldUrl = oldUrl; 927 e.metadata = this.selectedImageMetadata_; 928 this.dataModel_.dispatchEvent(e); 929 930 // Allow changing the 'Overwrite original' setting only if the user 931 // used Undo to restore the original image AND it is not a copy. 932 // Otherwise lock the setting in its current state. 933 var mayChangeOverwrite = !this.editor_.canUndo() && item.isOriginal(); 934 ImageUtil.setAttribute(this.options_, 'saved', !mayChangeOverwrite); 935 936 if (this.imageView_.getContentRevision() == 1) { // First edit. 937 ImageUtil.metrics.recordUserAction(ImageUtil.getMetricName('Edit')); 938 } 939 940 if (oldUrl != item.getUrl()) { 941 this.dataModel_.splice( 942 this.getSelectedIndex(), 0, new Gallery.Item(oldUrl)); 943 // The ribbon will ignore the splice above and redraw after the 944 // select call below (while being obscured by the Editor toolbar, 945 // so there is no need for nice animation here). 946 // SlideMode will ignore the selection change as the displayed item 947 // index has not changed. 948 this.select(++this.displayedIndex_); 949 } 950 callback(); 951 cr.dispatchSimpleEvent(this, 'image-saved'); 952 }.bind(this)); 953 }; 954 955 /** 956 * Update caches when the selected item url has changed. 957 * 958 * @param {Event} event Event. 959 * @private 960 */ 961 SlideMode.prototype.onContentChange_ = function(event) { 962 var newUrl = event.item.getUrl(); 963 if (newUrl != event.oldUrl) { 964 this.imageView_.changeUrl(newUrl); 965 } 966 this.metadataCache_.clear(event.oldUrl, Gallery.METADATA_TYPE); 967 }; 968 969 /** 970 * Flash 'Saved' label briefly to indicate that the image has been saved. 971 * @private 972 */ 973 SlideMode.prototype.flashSavedLabel_ = function() { 974 var setLabelHighlighted = 975 ImageUtil.setAttribute.bind(null, this.savedLabel_, 'highlighted'); 976 setTimeout(setLabelHighlighted.bind(null, true), 0); 977 setTimeout(setLabelHighlighted.bind(null, false), 300); 978 }; 979 980 /** 981 * Local storage key for the 'Overwrite original' setting. 982 * @type {string} 983 */ 984 SlideMode.OVERWRITE_KEY = 'gallery-overwrite-original'; 985 986 /** 987 * Local storage key for the number of times that 988 * the overwrite info bubble has been displayed. 989 * @type {string} 990 */ 991 SlideMode.OVERWRITE_BUBBLE_KEY = 'gallery-overwrite-bubble'; 992 993 /** 994 * Max number that the overwrite info bubble is shown. 995 * @type {number} 996 */ 997 SlideMode.OVERWRITE_BUBBLE_MAX_TIMES = 5; 998 999 /** 1000 * @return {boolean} True if 'Overwrite original' is set. 1001 * @private 1002 */ 1003 SlideMode.prototype.shouldOverwriteOriginal_ = function() { 1004 return this.overwriteOriginal_.checked; 1005 }; 1006 1007 /** 1008 * 'Overwrite original' checkbox handler. 1009 * @param {Event} event Event. 1010 * @private 1011 */ 1012 SlideMode.prototype.onOverwriteOriginalClick_ = function(event) { 1013 util.platform.setPreference(SlideMode.OVERWRITE_KEY, event.target.checked); 1014 }; 1015 1016 /** 1017 * Overwrite info bubble close handler. 1018 * @private 1019 */ 1020 SlideMode.prototype.onCloseBubble_ = function() { 1021 this.bubble_.hidden = true; 1022 util.platform.setPreference(SlideMode.OVERWRITE_BUBBLE_KEY, 1023 SlideMode.OVERWRITE_BUBBLE_MAX_TIMES); 1024 }; 1025 1026 // Slideshow 1027 1028 /** 1029 * Slideshow interval in ms. 1030 */ 1031 SlideMode.SLIDESHOW_INTERVAL = 5000; 1032 1033 /** 1034 * First slideshow interval in ms. It should be shorter so that the user 1035 * is not guessing whether the button worked. 1036 */ 1037 SlideMode.SLIDESHOW_INTERVAL_FIRST = 1000; 1038 1039 /** 1040 * Empirically determined duration of the fullscreen toggle animation. 1041 */ 1042 SlideMode.FULLSCREEN_TOGGLE_DELAY = 500; 1043 1044 /** 1045 * @return {boolean} True if the slideshow is on. 1046 * @private 1047 */ 1048 SlideMode.prototype.isSlideshowOn_ = function() { 1049 return this.container_.hasAttribute('slideshow'); 1050 }; 1051 1052 /** 1053 * Start the slideshow. 1054 * @param {number=} opt_interval First interval in ms. 1055 * @param {Event=} opt_event Event. 1056 */ 1057 SlideMode.prototype.startSlideshow = function(opt_interval, opt_event) { 1058 // Set the attribute early to prevent the toolbar from flashing when 1059 // the slideshow is being started from the mosaic view. 1060 this.container_.setAttribute('slideshow', 'playing'); 1061 1062 if (this.active_) { 1063 this.stopEditing_(); 1064 } else { 1065 // We are in the Mosaic mode. Toggle the mode but remember to return. 1066 this.leaveAfterSlideshow_ = true; 1067 this.toggleMode_(this.startSlideshow.bind( 1068 this, SlideMode.SLIDESHOW_INTERVAL, opt_event)); 1069 return; 1070 } 1071 1072 if (opt_event) // Caused by user action, notify the Gallery. 1073 cr.dispatchSimpleEvent(this, 'useraction'); 1074 1075 this.fullscreenBeforeSlideshow_ = util.isFullScreen(this.context_.appWindow); 1076 if (!this.fullscreenBeforeSlideshow_) { 1077 // Wait until the zoom animation from the mosaic mode is done. 1078 setTimeout(this.toggleFullScreen_.bind(this), 1079 ImageView.ZOOM_ANIMATION_DURATION); 1080 opt_interval = (opt_interval || SlideMode.SLIDESHOW_INTERVAL) + 1081 SlideMode.FULLSCREEN_TOGGLE_DELAY; 1082 } 1083 1084 this.resumeSlideshow_(opt_interval); 1085 }; 1086 1087 /** 1088 * Stop the slideshow. 1089 * @param {Event=} opt_event Event. 1090 * @private 1091 */ 1092 SlideMode.prototype.stopSlideshow_ = function(opt_event) { 1093 if (!this.isSlideshowOn_()) 1094 return; 1095 1096 if (opt_event) // Caused by user action, notify the Gallery. 1097 cr.dispatchSimpleEvent(this, 'useraction'); 1098 1099 this.pauseSlideshow_(); 1100 this.container_.removeAttribute('slideshow'); 1101 1102 // Do not restore fullscreen if we exited fullscreen while in slideshow. 1103 var fullscreen = util.isFullScreen(this.context_.appWindow); 1104 var toggleModeDelay = 0; 1105 if (!this.fullscreenBeforeSlideshow_ && fullscreen) { 1106 this.toggleFullScreen_(); 1107 toggleModeDelay = SlideMode.FULLSCREEN_TOGGLE_DELAY; 1108 } 1109 if (this.leaveAfterSlideshow_) { 1110 this.leaveAfterSlideshow_ = false; 1111 setTimeout(this.toggleMode_.bind(this), toggleModeDelay); 1112 } 1113 }; 1114 1115 /** 1116 * @return {boolean} True if the slideshow is playing (not paused). 1117 * @private 1118 */ 1119 SlideMode.prototype.isSlideshowPlaying_ = function() { 1120 return this.container_.getAttribute('slideshow') == 'playing'; 1121 }; 1122 1123 /** 1124 * Pause/resume the slideshow. 1125 * @private 1126 */ 1127 SlideMode.prototype.toggleSlideshowPause_ = function() { 1128 cr.dispatchSimpleEvent(this, 'useraction'); // Show the tools. 1129 if (this.isSlideshowPlaying_()) { 1130 this.pauseSlideshow_(); 1131 } else { 1132 this.resumeSlideshow_(SlideMode.SLIDESHOW_INTERVAL_FIRST); 1133 } 1134 }; 1135 1136 /** 1137 * @param {number=} opt_interval Slideshow interval in ms. 1138 * @private 1139 */ 1140 SlideMode.prototype.scheduleNextSlide_ = function(opt_interval) { 1141 console.assert(this.isSlideshowPlaying_(), 'Inconsistent slideshow state'); 1142 1143 if (this.slideShowTimeout_) 1144 clearTimeout(this.slideShowTimeout_); 1145 1146 this.slideShowTimeout_ = setTimeout(function() { 1147 this.slideShowTimeout_ = null; 1148 this.selectNext(1); 1149 }.bind(this), 1150 opt_interval || SlideMode.SLIDESHOW_INTERVAL); 1151 }; 1152 1153 /** 1154 * Resume the slideshow. 1155 * @param {number=} opt_interval Slideshow interval in ms. 1156 * @private 1157 */ 1158 SlideMode.prototype.resumeSlideshow_ = function(opt_interval) { 1159 this.container_.setAttribute('slideshow', 'playing'); 1160 this.scheduleNextSlide_(opt_interval); 1161 }; 1162 1163 /** 1164 * Pause the slideshow. 1165 * @private 1166 */ 1167 SlideMode.prototype.pauseSlideshow_ = function() { 1168 this.container_.setAttribute('slideshow', 'paused'); 1169 if (this.slideShowTimeout_) { 1170 clearTimeout(this.slideShowTimeout_); 1171 this.slideShowTimeout_ = null; 1172 } 1173 }; 1174 1175 /** 1176 * @return {boolean} True if the editor is active. 1177 */ 1178 SlideMode.prototype.isEditing = function() { 1179 return this.container_.hasAttribute('editing'); 1180 }; 1181 1182 /** 1183 * Stop editing. 1184 * @private 1185 */ 1186 SlideMode.prototype.stopEditing_ = function() { 1187 if (this.isEditing()) 1188 this.toggleEditor(); 1189 }; 1190 1191 /** 1192 * Activate/deactivate editor. 1193 * @param {Event=} opt_event Event. 1194 */ 1195 SlideMode.prototype.toggleEditor = function(opt_event) { 1196 if (opt_event) // Caused by user action, notify the Gallery. 1197 cr.dispatchSimpleEvent(this, 'useraction'); 1198 1199 if (!this.active_) { 1200 this.toggleMode_(this.toggleEditor.bind(this)); 1201 return; 1202 } 1203 1204 this.stopSlideshow_(); 1205 if (!this.isEditing() && this.isShowingVideo_()) 1206 return; // No editing for videos. 1207 1208 ImageUtil.setAttribute(this.container_, 'editing', !this.isEditing()); 1209 1210 if (this.isEditing()) { // isEditing has just been flipped to a new value. 1211 if (this.context_.readonlyDirName) { 1212 this.editor_.getPrompt().showAt( 1213 'top', 'readonly_warning', 0, this.context_.readonlyDirName); 1214 } 1215 } else { 1216 this.editor_.getPrompt().hide(); 1217 this.editor_.leaveModeGently(); 1218 } 1219 }; 1220 1221 /** 1222 * Display the error banner. 1223 * @param {string} message Message. 1224 * @private 1225 */ 1226 SlideMode.prototype.showErrorBanner_ = function(message) { 1227 if (message) { 1228 this.errorBanner_.textContent = this.displayStringFunction_(message); 1229 } 1230 ImageUtil.setAttribute(this.container_, 'error', !!message); 1231 }; 1232 1233 /** 1234 * Show/hide the busy spinner. 1235 * 1236 * @param {boolean} on True if show, false if hide. 1237 * @private 1238 */ 1239 SlideMode.prototype.showSpinner_ = function(on) { 1240 if (this.spinnerTimer_) { 1241 clearTimeout(this.spinnerTimer_); 1242 this.spinnerTimer_ = null; 1243 } 1244 1245 if (on) { 1246 this.spinnerTimer_ = setTimeout(function() { 1247 this.spinnerTimer_ = null; 1248 ImageUtil.setAttribute(this.container_, 'spinner', true); 1249 }.bind(this), 1000); 1250 } else { 1251 ImageUtil.setAttribute(this.container_, 'spinner', false); 1252 } 1253 }; 1254 1255 /** 1256 * @return {boolean} True if the current item is a video. 1257 * @private 1258 */ 1259 SlideMode.prototype.isShowingVideo_ = function() { 1260 return !!this.imageView_.getVideo(); 1261 }; 1262 1263 /** 1264 * Overlay that handles swipe gestures. Changes to the next or previous file. 1265 * @param {function(number)} callback A callback accepting the swipe direction 1266 * (1 means left, -1 right). 1267 * @constructor 1268 * @implements {ImageBuffer.Overlay} 1269 */ 1270 function SwipeOverlay(callback) { 1271 this.callback_ = callback; 1272 } 1273 1274 /** 1275 * Inherit ImageBuffer.Overlay. 1276 */ 1277 SwipeOverlay.prototype.__proto__ = ImageBuffer.Overlay.prototype; 1278 1279 /** 1280 * @param {number} x X pointer position. 1281 * @param {number} y Y pointer position. 1282 * @param {boolean} touch True if dragging caused by touch. 1283 * @return {function} The closure to call on drag. 1284 */ 1285 SwipeOverlay.prototype.getDragHandler = function(x, y, touch) { 1286 if (!touch) 1287 return null; 1288 var origin = x; 1289 var done = false; 1290 return function(x, y) { 1291 if (!done && origin - x > SwipeOverlay.SWIPE_THRESHOLD) { 1292 this.callback_(1); 1293 done = true; 1294 } else if (!done && x - origin > SwipeOverlay.SWIPE_THRESHOLD) { 1295 this.callback_(-1); 1296 done = true; 1297 } 1298 }.bind(this); 1299 }; 1300 1301 /** 1302 * If the user touched the image and moved the finger more than SWIPE_THRESHOLD 1303 * horizontally it's considered as a swipe gesture (change the current image). 1304 */ 1305 SwipeOverlay.SWIPE_THRESHOLD = 100; 1306