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