1// Copyright 2014 The Chromium Authors. All rights reserved. 2// Use of this source code is governed by a BSD-style license that can be 3// found in the LICENSE file. 4 5'use strict'; 6 7/** 8 * Slide mode displays a single image and has a set of controls to navigate 9 * between the images and to edit an image. 10 * 11 * @param {Element} container Main container element. 12 * @param {Element} content Content container element. 13 * @param {Element} toolbar Toolbar element. 14 * @param {ImageEditor.Prompt} prompt Prompt. 15 * @param {cr.ui.ArrayDataModel} dataModel Data model. 16 * @param {cr.ui.ListSelectionModel} selectionModel Selection model. 17 * @param {Object} context Context. 18 * @param {VolumeManager} volumeManager Volume manager. 19 * @param {function(function())} toggleMode Function to toggle the Gallery mode. 20 * @param {function(string):string} displayStringFunction String formatting 21 * function. 22 * @constructor 23 */ 24function SlideMode(container, content, toolbar, prompt, dataModel, 25 selectionModel, context, volumeManager, toggleMode, displayStringFunction) { 26 this.container_ = container; 27 this.document_ = container.ownerDocument; 28 this.content = content; 29 this.toolbar_ = toolbar; 30 this.prompt_ = prompt; 31 this.dataModel_ = dataModel; 32 this.selectionModel_ = selectionModel; 33 this.context_ = context; 34 this.volumeManager_ = volumeManager; 35 this.metadataCache_ = context.metadataCache; 36 this.toggleMode_ = toggleMode; 37 this.displayStringFunction_ = displayStringFunction; 38 39 this.onSelectionBound_ = this.onSelection_.bind(this); 40 this.onSpliceBound_ = this.onSplice_.bind(this); 41 42 // Unique numeric key, incremented per each load attempt used to discard 43 // old attempts. This can happen especially when changing selection fast or 44 // Internet connection is slow. 45 this.currentUniqueKey_ = 0; 46 47 this.initListeners_(); 48 this.initDom_(); 49} 50 51/** 52 * List of available editor modes. 53 * @type {Array.<ImageEditor.Mode>} 54 * @const 55 */ 56SlideMode.EDITOR_MODES = Object.freeze([ 57 new ImageEditor.Mode.InstantAutofix(), 58 new ImageEditor.Mode.Crop(), 59 new ImageEditor.Mode.Exposure(), 60 new ImageEditor.Mode.OneClick( 61 'rotate_left', 'GALLERY_ROTATE_LEFT', new Command.Rotate(-1)), 62 new ImageEditor.Mode.OneClick( 63 'rotate_right', 'GALLERY_ROTATE_RIGHT', new Command.Rotate(1)) 64]); 65 66/** 67 * Map of the key identifier and offset delta. 68 * @type {Object.<string, Array.<number>}) 69 * @const 70 */ 71SlideMode.KEY_OFFSET_MAP = Object.freeze({ 72 'Up': Object.freeze([0, 20]), 73 'Down': Object.freeze([0, -20]), 74 'Left': Object.freeze([20, 0]), 75 'Right': Object.freeze([-20, 0]) 76}); 77 78/** 79 * SlideMode extends cr.EventTarget. 80 */ 81SlideMode.prototype.__proto__ = cr.EventTarget.prototype; 82 83/** 84 * @return {string} Mode name. 85 */ 86SlideMode.prototype.getName = function() { return 'slide'; }; 87 88/** 89 * @return {string} Mode title. 90 */ 91SlideMode.prototype.getTitle = function() { return 'GALLERY_SLIDE'; }; 92 93/** 94 * @return {Viewport} Viewport. 95 */ 96SlideMode.prototype.getViewport = function() { return this.viewport_; }; 97 98/** 99 * Initialize the listeners. 100 * @private 101 */ 102SlideMode.prototype.initListeners_ = function() { 103 window.addEventListener('resize', this.onResize_.bind(this)); 104}; 105 106/** 107 * Initialize the UI. 108 * @private 109 */ 110SlideMode.prototype.initDom_ = function() { 111 // Container for displayed image. 112 this.imageContainer_ = util.createChild( 113 this.document_.querySelector('.content'), 'image-container'); 114 this.imageContainer_.addEventListener('click', this.onClick_.bind(this)); 115 116 this.document_.addEventListener('click', this.onDocumentClick_.bind(this)); 117 118 // Overwrite options and info bubble. 119 this.options_ = util.createChild( 120 this.toolbar_.querySelector('.filename-spacer'), 'options'); 121 122 this.savedLabel_ = util.createChild(this.options_, 'saved'); 123 this.savedLabel_.textContent = this.displayStringFunction_('GALLERY_SAVED'); 124 125 var overwriteOriginalBox = 126 util.createChild(this.options_, 'overwrite-original'); 127 128 this.overwriteOriginal_ = util.createChild( 129 overwriteOriginalBox, '', 'input'); 130 this.overwriteOriginal_.type = 'checkbox'; 131 this.overwriteOriginal_.id = 'overwrite-checkbox'; 132 chrome.storage.local.get(SlideMode.OVERWRITE_KEY, function(values) { 133 var value = values[SlideMode.OVERWRITE_KEY]; 134 // Out-of-the box default is 'true' 135 this.overwriteOriginal_.checked = 136 (value === 'false' || value === false) ? false : true; 137 }.bind(this)); 138 this.overwriteOriginal_.addEventListener('click', 139 this.onOverwriteOriginalClick_.bind(this)); 140 141 var overwriteLabel = util.createChild(overwriteOriginalBox, '', 'label'); 142 overwriteLabel.textContent = 143 this.displayStringFunction_('GALLERY_OVERWRITE_ORIGINAL'); 144 overwriteLabel.setAttribute('for', 'overwrite-checkbox'); 145 146 this.bubble_ = util.createChild(this.toolbar_, 'bubble'); 147 this.bubble_.hidden = true; 148 149 var bubbleContent = util.createChild(this.bubble_); 150 bubbleContent.innerHTML = this.displayStringFunction_( 151 'GALLERY_OVERWRITE_BUBBLE'); 152 153 util.createChild(this.bubble_, 'pointer bottom', 'span'); 154 155 var bubbleClose = util.createChild(this.bubble_, 'close-x'); 156 bubbleClose.addEventListener('click', this.onCloseBubble_.bind(this)); 157 158 // Ribbon and related controls. 159 this.arrowBox_ = util.createChild(this.container_, 'arrow-box'); 160 161 this.arrowLeft_ = 162 util.createChild(this.arrowBox_, 'arrow left tool dimmable'); 163 this.arrowLeft_.addEventListener('click', 164 this.advanceManually.bind(this, -1)); 165 util.createChild(this.arrowLeft_); 166 167 util.createChild(this.arrowBox_, 'arrow-spacer'); 168 169 this.arrowRight_ = 170 util.createChild(this.arrowBox_, 'arrow right tool dimmable'); 171 this.arrowRight_.addEventListener('click', 172 this.advanceManually.bind(this, 1)); 173 util.createChild(this.arrowRight_); 174 175 this.ribbonSpacer_ = this.toolbar_.querySelector('.ribbon-spacer'); 176 this.ribbon_ = new Ribbon( 177 this.document_, this.dataModel_, this.selectionModel_); 178 this.ribbonSpacer_.appendChild(this.ribbon_); 179 180 // Error indicator. 181 var errorWrapper = util.createChild(this.container_, 'prompt-wrapper'); 182 errorWrapper.setAttribute('pos', 'center'); 183 184 this.errorBanner_ = util.createChild(errorWrapper, 'error-banner'); 185 186 util.createChild(this.container_, 'spinner'); 187 188 var slideShowButton = this.toolbar_.querySelector('button.slideshow'); 189 slideShowButton.title = this.displayStringFunction_('GALLERY_SLIDESHOW'); 190 slideShowButton.addEventListener('click', 191 this.startSlideshow.bind(this, SlideMode.SLIDESHOW_INTERVAL_FIRST)); 192 193 var slideShowToolbar = 194 util.createChild(this.container_, 'tool slideshow-toolbar'); 195 util.createChild(slideShowToolbar, 'slideshow-play'). 196 addEventListener('click', this.toggleSlideshowPause_.bind(this)); 197 util.createChild(slideShowToolbar, 'slideshow-end'). 198 addEventListener('click', this.stopSlideshow_.bind(this)); 199 200 // Editor. 201 202 this.editButton_ = this.toolbar_.querySelector('button.edit'); 203 this.editButton_.title = this.displayStringFunction_('GALLERY_EDIT'); 204 this.editButton_.setAttribute('disabled', ''); // Disabled by default. 205 this.editButton_.addEventListener('click', this.toggleEditor.bind(this)); 206 207 this.printButton_ = this.toolbar_.querySelector('button.print'); 208 this.printButton_.title = this.displayStringFunction_('GALLERY_PRINT'); 209 this.printButton_.setAttribute('disabled', ''); // Disabled by default. 210 this.printButton_.addEventListener('click', this.print_.bind(this)); 211 212 this.editBarSpacer_ = this.toolbar_.querySelector('.edit-bar-spacer'); 213 this.editBarMain_ = util.createChild(this.editBarSpacer_, 'edit-main'); 214 215 this.editBarMode_ = util.createChild(this.container_, 'edit-modal'); 216 this.editBarModeWrapper_ = util.createChild( 217 this.editBarMode_, 'edit-modal-wrapper dimmable'); 218 this.editBarModeWrapper_.hidden = true; 219 220 // Objects supporting image display and editing. 221 this.viewport_ = new Viewport(); 222 223 this.imageView_ = new ImageView( 224 this.imageContainer_, 225 this.viewport_); 226 227 this.editor_ = new ImageEditor( 228 this.viewport_, 229 this.imageView_, 230 this.prompt_, 231 { 232 root: this.container_, 233 image: this.imageContainer_, 234 toolbar: this.editBarMain_, 235 mode: this.editBarModeWrapper_ 236 }, 237 SlideMode.EDITOR_MODES, 238 this.displayStringFunction_, 239 this.onToolsVisibilityChanged_.bind(this)); 240 241 this.touchHandlers_ = new TouchHandler(this.imageContainer_, this); 242}; 243 244/** 245 * Load items, display the selected item. 246 * @param {Rect} zoomFromRect Rectangle for zoom effect. 247 * @param {function} displayCallback Called when the image is displayed. 248 * @param {function} loadCallback Called when the image is displayed. 249 */ 250SlideMode.prototype.enter = function( 251 zoomFromRect, displayCallback, loadCallback) { 252 this.sequenceDirection_ = 0; 253 this.sequenceLength_ = 0; 254 255 var loadDone = function(loadType, delay) { 256 this.active_ = true; 257 258 this.selectionModel_.addEventListener('change', this.onSelectionBound_); 259 this.dataModel_.addEventListener('splice', this.onSpliceBound_); 260 261 ImageUtil.setAttribute(this.arrowBox_, 'active', this.getItemCount_() > 1); 262 this.ribbon_.enable(); 263 264 // Wait 1000ms after the animation is done, then prefetch the next image. 265 this.requestPrefetch(1, delay + 1000); 266 267 if (loadCallback) loadCallback(); 268 }.bind(this); 269 270 // The latest |leave| call might have left the image animating. Remove it. 271 this.unloadImage_(); 272 273 new Promise(function(fulfill) { 274 // If the items are empty, just show the error message. 275 if (this.getItemCount_() === 0) { 276 this.displayedIndex_ = -1; 277 //TODO(hirono) Show this message in the grid mode too. 278 this.showErrorBanner_('GALLERY_NO_IMAGES'); 279 fulfill(); 280 return; 281 } 282 283 // Remember the selection if it is empty or multiple. It will be restored 284 // in |leave| if the user did not changing the selection manually. 285 var currentSelection = this.selectionModel_.selectedIndexes; 286 if (currentSelection.length === 1) 287 this.savedSelection_ = null; 288 else 289 this.savedSelection_ = currentSelection; 290 291 // Ensure valid single selection. 292 // Note that the SlideMode object is not listening to selection change yet. 293 this.select(Math.max(0, this.getSelectedIndex())); 294 this.displayedIndex_ = this.getSelectedIndex(); 295 296 // Show the selected item ASAP, then complete the initialization 297 // (loading the ribbon thumbnails can take some time). 298 var selectedItem = this.getSelectedItem(); 299 300 // Load the image of the item. 301 this.loadItem_( 302 selectedItem, 303 zoomFromRect && this.imageView_.createZoomEffect(zoomFromRect), 304 displayCallback, 305 function(loadType, delay) { 306 fulfill(delay); 307 }); 308 }.bind(this)).then(function(delay) { 309 // Turn the mode active. 310 this.active_ = true; 311 ImageUtil.setAttribute(this.arrowBox_, 'active', this.getItemCount_() > 1); 312 this.ribbon_.enable(); 313 314 // Register handlers. 315 this.selectionModel_.addEventListener('change', this.onSelectionBound_); 316 this.dataModel_.addEventListener('splice', this.onSpliceBound_); 317 this.touchHandlers_.enabled = true; 318 319 // Wait 1000ms after the animation is done, then prefetch the next image. 320 this.requestPrefetch(1, delay + 1000); 321 322 // Call load callback. 323 if (loadCallback) 324 loadCallback(); 325 }.bind(this)).catch(function(error) { 326 console.error(error.stack, error); 327 }); 328}; 329 330/** 331 * Leave the mode. 332 * @param {Rect} zoomToRect Rectangle for zoom effect. 333 * @param {function} callback Called when the image is committed and 334 * the zoom-out animation has started. 335 */ 336SlideMode.prototype.leave = function(zoomToRect, callback) { 337 var commitDone = function() { 338 this.stopEditing_(); 339 this.stopSlideshow_(); 340 ImageUtil.setAttribute(this.arrowBox_, 'active', false); 341 this.selectionModel_.removeEventListener( 342 'change', this.onSelectionBound_); 343 this.dataModel_.removeEventListener('splice', this.onSpliceBound_); 344 this.ribbon_.disable(); 345 this.active_ = false; 346 if (this.savedSelection_) 347 this.selectionModel_.selectedIndexes = this.savedSelection_; 348 this.unloadImage_(zoomToRect); 349 callback(); 350 }.bind(this); 351 352 this.viewport_.resetView(); 353 if (this.getItemCount_() === 0) { 354 this.showErrorBanner_(false); 355 commitDone(); 356 } else { 357 this.commitItem_(commitDone); 358 } 359 360 // Disable the slide-mode only buttons when leaving. 361 this.editButton_.setAttribute('disabled', ''); 362 this.printButton_.setAttribute('disabled', ''); 363 364 // Disable touch operation. 365 this.touchHandlers_.enabled = false; 366}; 367 368 369/** 370 * Execute an action when the editor is not busy. 371 * 372 * @param {function} action Function to execute. 373 */ 374SlideMode.prototype.executeWhenReady = function(action) { 375 this.editor_.executeWhenReady(action); 376}; 377 378/** 379 * @return {boolean} True if the mode has active tools (that should not fade). 380 */ 381SlideMode.prototype.hasActiveTool = function() { 382 return this.isEditing(); 383}; 384 385/** 386 * @return {number} Item count. 387 * @private 388 */ 389SlideMode.prototype.getItemCount_ = function() { 390 return this.dataModel_.length; 391}; 392 393/** 394 * @param {number} index Index. 395 * @return {Gallery.Item} Item. 396 */ 397SlideMode.prototype.getItem = function(index) { 398 return this.dataModel_.item(index); 399}; 400 401/** 402 * @return {Gallery.Item} Selected index. 403 */ 404SlideMode.prototype.getSelectedIndex = function() { 405 return this.selectionModel_.selectedIndex; 406}; 407 408/** 409 * @return {Rect} Screen rectangle of the selected image. 410 */ 411SlideMode.prototype.getSelectedImageRect = function() { 412 if (this.getSelectedIndex() < 0) 413 return null; 414 else 415 return this.viewport_.getImageBoundsOnScreen(); 416}; 417 418/** 419 * @return {Gallery.Item} Selected item. 420 */ 421SlideMode.prototype.getSelectedItem = function() { 422 return this.getItem(this.getSelectedIndex()); 423}; 424 425/** 426 * Toggles the full screen mode. 427 * @private 428 */ 429SlideMode.prototype.toggleFullScreen_ = function() { 430 util.toggleFullScreen(this.context_.appWindow, 431 !util.isFullScreen(this.context_.appWindow)); 432}; 433 434/** 435 * Selection change handler. 436 * 437 * Commits the current image and displays the newly selected image. 438 * @private 439 */ 440SlideMode.prototype.onSelection_ = function() { 441 if (this.selectionModel_.selectedIndexes.length === 0) 442 return; // Temporary empty selection. 443 444 // Forget the saved selection if the user changed the selection manually. 445 if (!this.isSlideshowOn_()) 446 this.savedSelection_ = null; 447 448 if (this.getSelectedIndex() === this.displayedIndex_) 449 return; // Do not reselect. 450 451 this.commitItem_(this.loadSelectedItem_.bind(this)); 452}; 453 454/** 455 * Handles changes in tools visibility, and if the header is dimmed, then 456 * requests disabling the draggable app region. 457 * 458 * @private 459 */ 460SlideMode.prototype.onToolsVisibilityChanged_ = function() { 461 var headerDimmed = 462 this.document_.querySelector('.header').hasAttribute('dimmed'); 463 this.context_.onAppRegionChanged(!headerDimmed); 464}; 465 466/** 467 * Change the selection. 468 * 469 * @param {number} index New selected index. 470 * @param {number=} opt_slideHint Slide animation direction (-1|1). 471 */ 472SlideMode.prototype.select = function(index, opt_slideHint) { 473 this.slideHint_ = opt_slideHint; 474 this.selectionModel_.selectedIndex = index; 475 this.selectionModel_.leadIndex = index; 476}; 477 478/** 479 * Load the selected item. 480 * 481 * @private 482 */ 483SlideMode.prototype.loadSelectedItem_ = function() { 484 var slideHint = this.slideHint_; 485 this.slideHint_ = undefined; 486 487 var index = this.getSelectedIndex(); 488 if (index === this.displayedIndex_) 489 return; // Do not reselect. 490 491 var step = slideHint || (index - this.displayedIndex_); 492 493 if (Math.abs(step) != 1) { 494 // Long leap, the sequence is broken, we have no good prefetch candidate. 495 this.sequenceDirection_ = 0; 496 this.sequenceLength_ = 0; 497 } else if (this.sequenceDirection_ === step) { 498 // Keeping going in sequence. 499 this.sequenceLength_++; 500 } else { 501 // Reversed the direction. Reset the counter. 502 this.sequenceDirection_ = step; 503 this.sequenceLength_ = 1; 504 } 505 506 this.displayedIndex_ = index; 507 var selectedItem = this.getSelectedItem(); 508 509 if (this.sequenceLength_ <= 1) { 510 // We have just broke the sequence. Touch the current image so that it stays 511 // in the cache longer. 512 this.imageView_.prefetch(selectedItem); 513 } 514 515 function shouldPrefetch(loadType, step, sequenceLength) { 516 // Never prefetch when selecting out of sequence. 517 if (Math.abs(step) != 1) 518 return false; 519 520 // Always prefetch if the previous load was from cache. 521 if (loadType === ImageView.LOAD_TYPE_CACHED_FULL) 522 return true; 523 524 // Prefetch if we have been going in the same direction for long enough. 525 return sequenceLength >= 3; 526 } 527 528 this.currentUniqueKey_++; 529 var selectedUniqueKey = this.currentUniqueKey_; 530 531 // Discard, since another load has been invoked after this one. 532 if (selectedUniqueKey != this.currentUniqueKey_) 533 return; 534 535 this.loadItem_( 536 selectedItem, 537 new ImageView.Effect.Slide(step, this.isSlideshowPlaying_()), 538 function() {} /* no displayCallback */, 539 function(loadType, delay) { 540 // Discard, since another load has been invoked after this one. 541 if (selectedUniqueKey != this.currentUniqueKey_) 542 return; 543 if (shouldPrefetch(loadType, step, this.sequenceLength_)) 544 this.requestPrefetch(step, delay); 545 if (this.isSlideshowPlaying_()) 546 this.scheduleNextSlide_(); 547 }.bind(this)); 548}; 549 550/** 551 * Unload the current image. 552 * 553 * @param {Rect} zoomToRect Rectangle for zoom effect. 554 * @private 555 */ 556SlideMode.prototype.unloadImage_ = function(zoomToRect) { 557 this.imageView_.unload(zoomToRect); 558}; 559 560/** 561 * Data model 'splice' event handler. 562 * @param {Event} event Event. 563 * @private 564 */ 565SlideMode.prototype.onSplice_ = function(event) { 566 ImageUtil.setAttribute(this.arrowBox_, 'active', this.getItemCount_() > 1); 567 568 // Splice invalidates saved indices, drop the saved selection. 569 this.savedSelection_ = null; 570 571 if (event.removed.length != 1) 572 return; 573 574 // Delay the selection to let the ribbon splice handler work first. 575 setTimeout(function() { 576 if (event.index < this.dataModel_.length) { 577 // There is the next item, select it. 578 // The next item is now at the same index as the removed one, so we need 579 // to correct displayIndex_ so that loadSelectedItem_ does not think 580 // we are re-selecting the same item (and does right-to-left slide-in 581 // animation). 582 this.displayedIndex_ = event.index - 1; 583 this.select(event.index); 584 } else if (this.dataModel_.length) { 585 // Removed item is the rightmost, but there are more items. 586 this.select(event.index - 1); // Select the new last index. 587 } else { 588 // No items left. Unload the image and show the banner. 589 this.commitItem_(function() { 590 this.unloadImage_(); 591 this.showErrorBanner_('GALLERY_NO_IMAGES'); 592 }.bind(this)); 593 } 594 }.bind(this), 0); 595}; 596 597/** 598 * @param {number} direction -1 for left, 1 for right. 599 * @return {number} Next index in the given direction, with wrapping. 600 * @private 601 */ 602SlideMode.prototype.getNextSelectedIndex_ = function(direction) { 603 function advance(index, limit) { 604 index += (direction > 0 ? 1 : -1); 605 if (index < 0) 606 return limit - 1; 607 if (index === limit) 608 return 0; 609 return index; 610 } 611 612 // If the saved selection is multiple the Slideshow should cycle through 613 // the saved selection. 614 if (this.isSlideshowOn_() && 615 this.savedSelection_ && this.savedSelection_.length > 1) { 616 var pos = advance(this.savedSelection_.indexOf(this.getSelectedIndex()), 617 this.savedSelection_.length); 618 return this.savedSelection_[pos]; 619 } else { 620 return advance(this.getSelectedIndex(), this.getItemCount_()); 621 } 622}; 623 624/** 625 * Advance the selection based on the pressed key ID. 626 * @param {string} keyID Key identifier. 627 */ 628SlideMode.prototype.advanceWithKeyboard = function(keyID) { 629 var prev = (keyID === 'Up' || 630 keyID === 'Left' || 631 keyID === 'MediaPreviousTrack'); 632 this.advanceManually(prev ? -1 : 1); 633}; 634 635/** 636 * Advance the selection as a result of a user action (as opposed to an 637 * automatic change in the slideshow mode). 638 * @param {number} direction -1 for left, 1 for right. 639 */ 640SlideMode.prototype.advanceManually = function(direction) { 641 if (this.isSlideshowPlaying_()) 642 this.pauseSlideshow_(); 643 cr.dispatchSimpleEvent(this, 'useraction'); 644 this.selectNext(direction); 645}; 646 647/** 648 * Select the next item. 649 * @param {number} direction -1 for left, 1 for right. 650 */ 651SlideMode.prototype.selectNext = function(direction) { 652 this.select(this.getNextSelectedIndex_(direction), direction); 653}; 654 655/** 656 * Select the first item. 657 */ 658SlideMode.prototype.selectFirst = function() { 659 this.select(0); 660}; 661 662/** 663 * Select the last item. 664 */ 665SlideMode.prototype.selectLast = function() { 666 this.select(this.getItemCount_() - 1); 667}; 668 669// Loading/unloading 670 671/** 672 * Load and display an item. 673 * 674 * @param {Gallery.Item} item Item. 675 * @param {Object} effect Transition effect object. 676 * @param {function} displayCallback Called when the image is displayed 677 * (which can happen before the image load due to caching). 678 * @param {function} loadCallback Called when the image is fully loaded. 679 * @private 680 */ 681SlideMode.prototype.loadItem_ = function( 682 item, effect, displayCallback, loadCallback) { 683 var entry = item.getEntry(); 684 var metadata = item.getMetadata(); 685 this.showSpinner_(true); 686 687 var loadDone = function(loadType, delay, error) { 688 this.showSpinner_(false); 689 if (loadType === ImageView.LOAD_TYPE_ERROR) { 690 // if we have a specific error, then display it 691 if (error) { 692 this.showErrorBanner_(error); 693 } else { 694 // otherwise try to infer general error 695 this.showErrorBanner_('GALLERY_IMAGE_ERROR'); 696 } 697 } else if (loadType === ImageView.LOAD_TYPE_OFFLINE) { 698 this.showErrorBanner_('GALLERY_IMAGE_OFFLINE'); 699 } 700 701 ImageUtil.metrics.recordUserAction(ImageUtil.getMetricName('View')); 702 703 var toMillions = function(number) { 704 return Math.round(number / (1000 * 1000)); 705 }; 706 707 ImageUtil.metrics.recordSmallCount(ImageUtil.getMetricName('Size.MB'), 708 toMillions(metadata.filesystem.size)); 709 710 var canvas = this.imageView_.getCanvas(); 711 ImageUtil.metrics.recordSmallCount(ImageUtil.getMetricName('Size.MPix'), 712 toMillions(canvas.width * canvas.height)); 713 714 var extIndex = entry.name.lastIndexOf('.'); 715 var ext = extIndex < 0 ? '' : 716 entry.name.substr(extIndex + 1).toLowerCase(); 717 if (ext === 'jpeg') ext = 'jpg'; 718 ImageUtil.metrics.recordEnum( 719 ImageUtil.getMetricName('FileType'), ext, ImageUtil.FILE_TYPES); 720 721 // Enable or disable buttons for editing and printing. 722 if (error) { 723 this.editButton_.setAttribute('disabled', ''); 724 this.printButton_.setAttribute('disabled', ''); 725 } else { 726 this.editButton_.removeAttribute('disabled'); 727 this.printButton_.removeAttribute('disabled'); 728 } 729 730 // For once edited image, disallow the 'overwrite' setting change. 731 ImageUtil.setAttribute(this.options_, 'saved', 732 !this.getSelectedItem().isOriginal()); 733 734 chrome.storage.local.get(SlideMode.OVERWRITE_BUBBLE_KEY, 735 function(values) { 736 var times = values[SlideMode.OVERWRITE_BUBBLE_KEY] || 0; 737 if (times < SlideMode.OVERWRITE_BUBBLE_MAX_TIMES) { 738 this.bubble_.hidden = false; 739 if (this.isEditing()) { 740 var items = {}; 741 items[SlideMode.OVERWRITE_BUBBLE_KEY] = times + 1; 742 chrome.storage.local.set(items); 743 } 744 } 745 }.bind(this)); 746 747 loadCallback(loadType, delay); 748 }.bind(this); 749 750 var displayDone = function() { 751 cr.dispatchSimpleEvent(this, 'image-displayed'); 752 displayCallback(); 753 }.bind(this); 754 755 this.editor_.openSession( 756 item, 757 effect, 758 this.saveCurrentImage_.bind(this, item), 759 displayDone, 760 loadDone); 761}; 762 763/** 764 * Commit changes to the current item and reset all messages/indicators. 765 * 766 * @param {function} callback Callback. 767 * @private 768 */ 769SlideMode.prototype.commitItem_ = function(callback) { 770 this.showSpinner_(false); 771 this.showErrorBanner_(false); 772 this.editor_.getPrompt().hide(); 773 this.editor_.closeSession(callback); 774}; 775 776/** 777 * Request a prefetch for the next image. 778 * 779 * @param {number} direction -1 or 1. 780 * @param {number} delay Delay in ms. Used to prevent the CPU-heavy image 781 * loading from disrupting the animation that might be still in progress. 782 */ 783SlideMode.prototype.requestPrefetch = function(direction, delay) { 784 if (this.getItemCount_() <= 1) return; 785 786 var index = this.getNextSelectedIndex_(direction); 787 this.imageView_.prefetch(this.getItem(index), delay); 788}; 789 790// Event handlers. 791 792/** 793 * Unload handler, to be called from the top frame. 794 * @param {boolean} exiting True if the app is exiting. 795 */ 796SlideMode.prototype.onUnload = function(exiting) { 797}; 798 799/** 800 * Click handler for the image container. 801 * 802 * @param {Event} event Mouse click event. 803 * @private 804 */ 805SlideMode.prototype.onClick_ = function(event) { 806}; 807 808/** 809 * Click handler for the entire document. 810 * @param {Event} e Mouse click event. 811 * @private 812 */ 813SlideMode.prototype.onDocumentClick_ = function(e) { 814 // Close the bubble if clicked outside of it and if it is visible. 815 if (!this.bubble_.contains(e.target) && 816 !this.editButton_.contains(e.target) && 817 !this.arrowLeft_.contains(e.target) && 818 !this.arrowRight_.contains(e.target) && 819 !this.bubble_.hidden) { 820 this.bubble_.hidden = true; 821 } 822}; 823 824/** 825 * Keydown handler. 826 * 827 * @param {Event} event Event. 828 * @return {boolean} True if handled. 829 */ 830SlideMode.prototype.onKeyDown = function(event) { 831 var keyID = util.getKeyModifiers(event) + event.keyIdentifier; 832 833 if (this.isSlideshowOn_()) { 834 switch (keyID) { 835 case 'U+001B': // Escape exits the slideshow. 836 case 'MediaStop': 837 this.stopSlideshow_(event); 838 break; 839 840 case 'U+0020': // Space pauses/resumes the slideshow. 841 case 'MediaPlayPause': 842 this.toggleSlideshowPause_(); 843 break; 844 845 case 'Up': 846 case 'Down': 847 case 'Left': 848 case 'Right': 849 case 'MediaNextTrack': 850 case 'MediaPreviousTrack': 851 this.advanceWithKeyboard(keyID); 852 break; 853 } 854 return true; // Consume all keystrokes in the slideshow mode. 855 } 856 857 if (this.isEditing() && this.editor_.onKeyDown(event)) 858 return true; 859 860 switch (keyID) { 861 case 'Ctrl-U+0050': // Ctrl+'p' prints the current image. 862 if (!this.printButton_.hasAttribute('disabled')) 863 this.print_(); 864 break; 865 866 case 'U+0045': // 'e' toggles the editor. 867 if (!this.editButton_.hasAttribute('disabled')) 868 this.toggleEditor(event); 869 break; 870 871 case 'U+001B': // Escape 872 if (this.isEditing()) { 873 this.toggleEditor(event); 874 } else if (this.viewport_.isZoomed()) { 875 this.viewport_.resetView(); 876 this.touchHandlers_.stopOperation(); 877 this.imageView_.applyViewportChange(); 878 } else { 879 return false; // Not handled. 880 } 881 break; 882 883 case 'Home': 884 this.selectFirst(); 885 break; 886 case 'End': 887 this.selectLast(); 888 break; 889 case 'Up': 890 case 'Down': 891 case 'Left': 892 case 'Right': 893 if (!this.isEditing() && this.viewport_.isZoomed()) { 894 var delta = SlideMode.KEY_OFFSET_MAP[keyID]; 895 this.viewport_.setOffset( 896 ~~(this.viewport_.getOffsetX() + 897 delta[0] * this.viewport_.getZoom()), 898 ~~(this.viewport_.getOffsetY() + 899 delta[1] * this.viewport_.getZoom())); 900 this.touchHandlers_.stopOperation(); 901 this.imageView_.applyViewportChange(); 902 } else { 903 this.advanceWithKeyboard(keyID); 904 } 905 break; 906 case 'MediaNextTrack': 907 case 'MediaPreviousTrack': 908 this.advanceWithKeyboard(keyID); 909 break; 910 911 case 'Ctrl-U+00BB': // Ctrl+'=' zoom in. 912 if (!this.isEditing()) { 913 this.viewport_.zoomIn(); 914 this.touchHandlers_.stopOperation(); 915 this.imageView_.applyViewportChange(); 916 } 917 break; 918 919 case 'Ctrl-U+00BD': // Ctrl+'-' zoom out. 920 if (!this.isEditing()) { 921 this.viewport_.zoomOut(); 922 this.touchHandlers_.stopOperation(); 923 this.imageView_.applyViewportChange(); 924 } 925 break; 926 927 case 'Ctrl-U+0030': // Ctrl+'0' zoom reset. 928 if (!this.isEditing()) { 929 this.viewport_.setZoom(1.0); 930 this.touchHandlers_.stopOperation(); 931 this.imageView_.applyViewportChange(); 932 } 933 break; 934 } 935 936 return true; 937}; 938 939/** 940 * Resize handler. 941 * @private 942 */ 943SlideMode.prototype.onResize_ = function() { 944 this.viewport_.setScreenSize( 945 this.container_.clientWidth, this.container_.clientHeight); 946 this.touchHandlers_.stopOperation(); 947 this.editor_.getBuffer().draw(); 948}; 949 950/** 951 * Update thumbnails. 952 */ 953SlideMode.prototype.updateThumbnails = function() { 954 this.ribbon_.reset(); 955 if (this.active_) 956 this.ribbon_.redraw(); 957}; 958 959// Saving 960 961/** 962 * Save the current image to a file. 963 * 964 * @param {Gallery.Item} item Item to save the image. 965 * @param {function} callback Callback. 966 * @private 967 */ 968SlideMode.prototype.saveCurrentImage_ = function(item, callback) { 969 this.showSpinner_(true); 970 971 var savedPromise = this.dataModel_.saveItem( 972 this.volumeManager_, 973 item, 974 this.imageView_.getCanvas(), 975 this.shouldOverwriteOriginal_()); 976 977 savedPromise.catch(function(error) { 978 // TODO(hirono): Implement write error handling. 979 // Until then pretend that the save succeeded. 980 console.error(error.stack || error); 981 }).then(function() { 982 this.showSpinner_(false); 983 this.flashSavedLabel_(); 984 985 // Allow changing the 'Overwrite original' setting only if the user 986 // used Undo to restore the original image AND it is not a copy. 987 // Otherwise lock the setting in its current state. 988 var mayChangeOverwrite = !this.editor_.canUndo() && item.isOriginal(); 989 ImageUtil.setAttribute(this.options_, 'saved', !mayChangeOverwrite); 990 991 // Record UMA for the first edit. 992 if (this.imageView_.getContentRevision() === 1) 993 ImageUtil.metrics.recordUserAction(ImageUtil.getMetricName('Edit')); 994 995 callback(); 996 cr.dispatchSimpleEvent(this, 'image-saved'); 997 }.bind(this)).catch(function(error) { 998 console.error(error.stack || error); 999 }); 1000}; 1001 1002/** 1003 * Flash 'Saved' label briefly to indicate that the image has been saved. 1004 * @private 1005 */ 1006SlideMode.prototype.flashSavedLabel_ = function() { 1007 var setLabelHighlighted = 1008 ImageUtil.setAttribute.bind(null, this.savedLabel_, 'highlighted'); 1009 setTimeout(setLabelHighlighted.bind(null, true), 0); 1010 setTimeout(setLabelHighlighted.bind(null, false), 300); 1011}; 1012 1013/** 1014 * Local storage key for the 'Overwrite original' setting. 1015 * @type {string} 1016 */ 1017SlideMode.OVERWRITE_KEY = 'gallery-overwrite-original'; 1018 1019/** 1020 * Local storage key for the number of times that 1021 * the overwrite info bubble has been displayed. 1022 * @type {string} 1023 */ 1024SlideMode.OVERWRITE_BUBBLE_KEY = 'gallery-overwrite-bubble'; 1025 1026/** 1027 * Max number that the overwrite info bubble is shown. 1028 * @type {number} 1029 */ 1030SlideMode.OVERWRITE_BUBBLE_MAX_TIMES = 5; 1031 1032/** 1033 * @return {boolean} True if 'Overwrite original' is set. 1034 * @private 1035 */ 1036SlideMode.prototype.shouldOverwriteOriginal_ = function() { 1037 return this.overwriteOriginal_.checked; 1038}; 1039 1040/** 1041 * 'Overwrite original' checkbox handler. 1042 * @param {Event} event Event. 1043 * @private 1044 */ 1045SlideMode.prototype.onOverwriteOriginalClick_ = function(event) { 1046 var items = {}; 1047 items[SlideMode.OVERWRITE_KEY] = event.target.checked; 1048 chrome.storage.local.set(items); 1049}; 1050 1051/** 1052 * Overwrite info bubble close handler. 1053 * @private 1054 */ 1055SlideMode.prototype.onCloseBubble_ = function() { 1056 this.bubble_.hidden = true; 1057 var items = {}; 1058 items[SlideMode.OVERWRITE_BUBBLE_KEY] = 1059 SlideMode.OVERWRITE_BUBBLE_MAX_TIMES; 1060 chrome.storage.local.set(items); 1061}; 1062 1063// Slideshow 1064 1065/** 1066 * Slideshow interval in ms. 1067 */ 1068SlideMode.SLIDESHOW_INTERVAL = 5000; 1069 1070/** 1071 * First slideshow interval in ms. It should be shorter so that the user 1072 * is not guessing whether the button worked. 1073 */ 1074SlideMode.SLIDESHOW_INTERVAL_FIRST = 1000; 1075 1076/** 1077 * Empirically determined duration of the fullscreen toggle animation. 1078 */ 1079SlideMode.FULLSCREEN_TOGGLE_DELAY = 500; 1080 1081/** 1082 * @return {boolean} True if the slideshow is on. 1083 * @private 1084 */ 1085SlideMode.prototype.isSlideshowOn_ = function() { 1086 return this.container_.hasAttribute('slideshow'); 1087}; 1088 1089/** 1090 * Starts the slideshow. 1091 * @param {number=} opt_interval First interval in ms. 1092 * @param {Event=} opt_event Event. 1093 */ 1094SlideMode.prototype.startSlideshow = function(opt_interval, opt_event) { 1095 // Reset zoom. 1096 this.viewport_.resetView(); 1097 this.imageView_.applyViewportChange(); 1098 1099 // Disable touch operation. 1100 this.touchHandlers_.enabled = false; 1101 1102 // Set the attribute early to prevent the toolbar from flashing when 1103 // the slideshow is being started from the mosaic view. 1104 this.container_.setAttribute('slideshow', 'playing'); 1105 1106 if (this.active_) { 1107 this.stopEditing_(); 1108 } else { 1109 // We are in the Mosaic mode. Toggle the mode but remember to return. 1110 this.leaveAfterSlideshow_ = true; 1111 this.toggleMode_(this.startSlideshow.bind( 1112 this, SlideMode.SLIDESHOW_INTERVAL, opt_event)); 1113 return; 1114 } 1115 1116 if (opt_event) // Caused by user action, notify the Gallery. 1117 cr.dispatchSimpleEvent(this, 'useraction'); 1118 1119 this.fullscreenBeforeSlideshow_ = util.isFullScreen(this.context_.appWindow); 1120 if (!this.fullscreenBeforeSlideshow_) { 1121 // Wait until the zoom animation from the mosaic mode is done. 1122 setTimeout(this.toggleFullScreen_.bind(this), 1123 ImageView.ZOOM_ANIMATION_DURATION); 1124 opt_interval = (opt_interval || SlideMode.SLIDESHOW_INTERVAL) + 1125 SlideMode.FULLSCREEN_TOGGLE_DELAY; 1126 } 1127 1128 this.resumeSlideshow_(opt_interval); 1129}; 1130 1131/** 1132 * Stops the slideshow. 1133 * @param {Event=} opt_event Event. 1134 * @private 1135 */ 1136SlideMode.prototype.stopSlideshow_ = function(opt_event) { 1137 if (!this.isSlideshowOn_()) 1138 return; 1139 1140 if (opt_event) // Caused by user action, notify the Gallery. 1141 cr.dispatchSimpleEvent(this, 'useraction'); 1142 1143 this.pauseSlideshow_(); 1144 this.container_.removeAttribute('slideshow'); 1145 1146 // Do not restore fullscreen if we exited fullscreen while in slideshow. 1147 var fullscreen = util.isFullScreen(this.context_.appWindow); 1148 var toggleModeDelay = 0; 1149 if (!this.fullscreenBeforeSlideshow_ && fullscreen) { 1150 this.toggleFullScreen_(); 1151 toggleModeDelay = SlideMode.FULLSCREEN_TOGGLE_DELAY; 1152 } 1153 if (this.leaveAfterSlideshow_) { 1154 this.leaveAfterSlideshow_ = false; 1155 setTimeout(this.toggleMode_.bind(this), toggleModeDelay); 1156 } 1157 1158 // Re-enable touch operation. 1159 this.touchHandlers_.enabled = true; 1160}; 1161 1162/** 1163 * @return {boolean} True if the slideshow is playing (not paused). 1164 * @private 1165 */ 1166SlideMode.prototype.isSlideshowPlaying_ = function() { 1167 return this.container_.getAttribute('slideshow') === 'playing'; 1168}; 1169 1170/** 1171 * Pauses/resumes the slideshow. 1172 * @private 1173 */ 1174SlideMode.prototype.toggleSlideshowPause_ = function() { 1175 cr.dispatchSimpleEvent(this, 'useraction'); // Show the tools. 1176 if (this.isSlideshowPlaying_()) { 1177 this.pauseSlideshow_(); 1178 } else { 1179 this.resumeSlideshow_(SlideMode.SLIDESHOW_INTERVAL_FIRST); 1180 } 1181}; 1182 1183/** 1184 * @param {number=} opt_interval Slideshow interval in ms. 1185 * @private 1186 */ 1187SlideMode.prototype.scheduleNextSlide_ = function(opt_interval) { 1188 console.assert(this.isSlideshowPlaying_(), 'Inconsistent slideshow state'); 1189 1190 if (this.slideShowTimeout_) 1191 clearTimeout(this.slideShowTimeout_); 1192 1193 this.slideShowTimeout_ = setTimeout(function() { 1194 this.slideShowTimeout_ = null; 1195 this.selectNext(1); 1196 }.bind(this), opt_interval || SlideMode.SLIDESHOW_INTERVAL); 1197}; 1198 1199/** 1200 * Resumes the slideshow. 1201 * @param {number=} opt_interval Slideshow interval in ms. 1202 * @private 1203 */ 1204SlideMode.prototype.resumeSlideshow_ = function(opt_interval) { 1205 this.container_.setAttribute('slideshow', 'playing'); 1206 this.scheduleNextSlide_(opt_interval); 1207}; 1208 1209/** 1210 * Pauses the slideshow. 1211 * @private 1212 */ 1213SlideMode.prototype.pauseSlideshow_ = function() { 1214 this.container_.setAttribute('slideshow', 'paused'); 1215 if (this.slideShowTimeout_) { 1216 clearTimeout(this.slideShowTimeout_); 1217 this.slideShowTimeout_ = null; 1218 } 1219}; 1220 1221/** 1222 * @return {boolean} True if the editor is active. 1223 */ 1224SlideMode.prototype.isEditing = function() { 1225 return this.container_.hasAttribute('editing'); 1226}; 1227 1228/** 1229 * Stops editing. 1230 * @private 1231 */ 1232SlideMode.prototype.stopEditing_ = function() { 1233 if (this.isEditing()) 1234 this.toggleEditor(); 1235}; 1236 1237/** 1238 * Activate/deactivate editor. 1239 * @param {Event=} opt_event Event. 1240 */ 1241SlideMode.prototype.toggleEditor = function(opt_event) { 1242 if (opt_event) // Caused by user action, notify the Gallery. 1243 cr.dispatchSimpleEvent(this, 'useraction'); 1244 1245 if (!this.active_) { 1246 this.toggleMode_(this.toggleEditor.bind(this)); 1247 return; 1248 } 1249 1250 this.stopSlideshow_(); 1251 1252 ImageUtil.setAttribute(this.container_, 'editing', !this.isEditing()); 1253 1254 if (this.isEditing()) { // isEditing has just been flipped to a new value. 1255 // Reset zoom. 1256 this.viewport_.resetView(); 1257 this.imageView_.applyViewportChange(); 1258 if (this.context_.readonlyDirName) { 1259 this.editor_.getPrompt().showAt( 1260 'top', 'GALLERY_READONLY_WARNING', 0, this.context_.readonlyDirName); 1261 } 1262 this.touchHandlers_.enabled = false; 1263 } else { 1264 this.editor_.getPrompt().hide(); 1265 this.editor_.leaveModeGently(); 1266 this.touchHandlers_.enabled = true; 1267 } 1268}; 1269 1270/** 1271 * Prints the current item. 1272 * @private 1273 */ 1274SlideMode.prototype.print_ = function() { 1275 cr.dispatchSimpleEvent(this, 'useraction'); 1276 window.print(); 1277}; 1278 1279/** 1280 * Displays the error banner. 1281 * @param {string} message Message. 1282 * @private 1283 */ 1284SlideMode.prototype.showErrorBanner_ = function(message) { 1285 if (message) { 1286 this.errorBanner_.textContent = this.displayStringFunction_(message); 1287 } 1288 ImageUtil.setAttribute(this.container_, 'error', !!message); 1289}; 1290 1291/** 1292 * Shows/hides the busy spinner. 1293 * 1294 * @param {boolean} on True if show, false if hide. 1295 * @private 1296 */ 1297SlideMode.prototype.showSpinner_ = function(on) { 1298 if (this.spinnerTimer_) { 1299 clearTimeout(this.spinnerTimer_); 1300 this.spinnerTimer_ = null; 1301 } 1302 1303 if (on) { 1304 this.spinnerTimer_ = setTimeout(function() { 1305 this.spinnerTimer_ = null; 1306 ImageUtil.setAttribute(this.container_, 'spinner', true); 1307 }.bind(this), 1000); 1308 } else { 1309 ImageUtil.setAttribute(this.container_, 'spinner', false); 1310 } 1311}; 1312 1313/** 1314 * Apply the change of viewport. 1315 */ 1316SlideMode.prototype.applyViewportChange = function() { 1317 this.imageView_.applyViewportChange(); 1318}; 1319 1320/** 1321 * Touch handlers of the slide mode. 1322 * @param {DOMElement} targetElement Event source. 1323 * @param {SlideMode} slideMode Slide mode to be operated by the handler. 1324 * @constructor 1325 */ 1326function TouchHandler(targetElement, slideMode) { 1327 /** 1328 * Event source. 1329 * @type {DOMElement} 1330 * @private 1331 */ 1332 this.targetElement_ = targetElement; 1333 1334 /** 1335 * Target of touch operations. 1336 * @type {SlideMode} 1337 * @private 1338 */ 1339 this.slideMode_ = slideMode; 1340 1341 /** 1342 * Flag to enable/disable touch operation. 1343 * @type {boolean} 1344 * @private 1345 */ 1346 this.enabled_ = true; 1347 1348 /** 1349 * Whether it is in a touch operation that is started from targetElement or 1350 * not. 1351 * @type {boolean} 1352 * @private 1353 */ 1354 this.touchStarted_ = false; 1355 1356 /** 1357 * The swipe action that should happen only once in an operation is already 1358 * done or not. 1359 * @type {boolean} 1360 * @private 1361 */ 1362 this.done_ = false; 1363 1364 /** 1365 * Event on beginning of the current gesture. 1366 * The variable is updated when the number of touch finger changed. 1367 * @type {TouchEvent} 1368 * @private 1369 */ 1370 this.gestureStartEvent_ = null; 1371 1372 /** 1373 * Rotation value on beginning of the current gesture. 1374 * @type {number} 1375 * @private 1376 */ 1377 this.gestureStartRotation_ = 0; 1378 1379 /** 1380 * Last touch event. 1381 * @type {TouchEvent} 1382 * @private 1383 */ 1384 this.lastEvent_ = null; 1385 1386 /** 1387 * Zoom value just after last touch event. 1388 * @type {number} 1389 * @private 1390 */ 1391 this.lastZoom_ = 1.0; 1392 1393 targetElement.addEventListener('touchstart', this.onTouchStart_.bind(this)); 1394 var onTouchEventBound = this.onTouchEvent_.bind(this); 1395 targetElement.ownerDocument.addEventListener('touchmove', onTouchEventBound); 1396 targetElement.ownerDocument.addEventListener('touchend', onTouchEventBound); 1397 1398 targetElement.addEventListener('mousewheel', this.onMouseWheel_.bind(this)); 1399} 1400 1401/** 1402 * If the user touched the image and moved the finger more than SWIPE_THRESHOLD 1403 * horizontally it's considered as a swipe gesture (change the current image). 1404 * @type {number} 1405 * @const 1406 */ 1407TouchHandler.SWIPE_THRESHOLD = 100; 1408 1409/** 1410 * Rotation threshold in degrees. 1411 * @type {number} 1412 * @const 1413 */ 1414TouchHandler.ROTATION_THRESHOLD = 25; 1415 1416/** 1417 * Obtains distance between fingers. 1418 * @param {TouchEvent} event Touch event. It should include more than two 1419 * touches. 1420 * @return {boolean} Distance between touch[0] and touch[1]. 1421 */ 1422TouchHandler.getDistance = function(event) { 1423 var touch1 = event.touches[0]; 1424 var touch2 = event.touches[1]; 1425 var dx = touch1.clientX - touch2.clientX; 1426 var dy = touch1.clientY - touch2.clientY; 1427 return Math.sqrt(dx * dx + dy * dy); 1428}; 1429 1430/** 1431 * Obtains the degrees of the pinch twist angle. 1432 * @param {TouchEvent} event1 Start touch event. It should include more than two 1433 * touches. 1434 * @param {TouchEvent} event2 Current touch event. It should include more than 1435 * two touches. 1436 * @return {number} Degrees of the pinch twist angle. 1437 */ 1438TouchHandler.getTwistAngle = function(event1, event2) { 1439 var dx1 = event1.touches[1].clientX - event1.touches[0].clientX; 1440 var dy1 = event1.touches[1].clientY - event1.touches[0].clientY; 1441 var dx2 = event2.touches[1].clientX - event2.touches[0].clientX; 1442 var dy2 = event2.touches[1].clientY - event2.touches[0].clientY; 1443 var innerProduct = dx1 * dx2 + dy1 * dy2; // |v1| * |v2| * cos(t) = x / r 1444 var outerProduct = dx1 * dy2 - dy1 * dx2; // |v1| * |v2| * sin(t) = y / r 1445 return Math.atan2(outerProduct, innerProduct) * 180 / Math.PI; // atan(y / x) 1446}; 1447 1448TouchHandler.prototype = { 1449 /** 1450 * @param {boolean} flag New value. 1451 */ 1452 set enabled(flag) { 1453 this.enabled_ = flag; 1454 if (!this.enabled_) 1455 this.stopOperation(); 1456 } 1457}; 1458 1459/** 1460 * Stops the current touch operation. 1461 */ 1462TouchHandler.prototype.stopOperation = function() { 1463 this.touchStarted_ = false; 1464 this.done_ = false; 1465 this.gestureStartEvent_ = null; 1466 this.lastEvent_ = null; 1467 this.lastZoom_ = 1.0; 1468}; 1469 1470/** 1471 * Handles touch start events. 1472 * @param {TouchEvent} event Touch event. 1473 * @private 1474 */ 1475TouchHandler.prototype.onTouchStart_ = function(event) { 1476 if (this.enabled_ && event.touches.length === 1) 1477 this.touchStarted_ = true; 1478}; 1479 1480/** 1481 * Handles touch move and touch end events. 1482 * @param {TouchEvent} event Touch event. 1483 * @private 1484 */ 1485TouchHandler.prototype.onTouchEvent_ = function(event) { 1486 // Check if the current touch operation started from the target element or 1487 // not. 1488 if (!this.touchStarted_) 1489 return; 1490 1491 // Check if the current touch operation ends with the event. 1492 if (event.touches.length === 0) { 1493 this.stopOperation(); 1494 return; 1495 } 1496 1497 // Check if a new gesture started or not. 1498 var viewport = this.slideMode_.getViewport(); 1499 if (!this.lastEvent_ || 1500 this.lastEvent_.touches.length !== event.touches.length) { 1501 if (event.touches.length === 2 || 1502 event.touches.length === 1) { 1503 this.gestureStartEvent_ = event; 1504 this.gestureStartRotation_ = viewport.getRotation(); 1505 this.lastEvent_ = event; 1506 this.lastZoom_ = viewport.getZoom(); 1507 } else { 1508 this.gestureStartEvent_ = null; 1509 this.gestureStartRotation_ = 0; 1510 this.lastEvent_ = null; 1511 this.lastZoom_ = 1.0; 1512 } 1513 return; 1514 } 1515 1516 // Handle the gesture movement. 1517 switch (event.touches.length) { 1518 case 1: 1519 if (viewport.isZoomed()) { 1520 // Scrolling an image by swipe. 1521 var dx = event.touches[0].screenX - this.lastEvent_.touches[0].screenX; 1522 var dy = event.touches[0].screenY - this.lastEvent_.touches[0].screenY; 1523 viewport.setOffset( 1524 viewport.getOffsetX() + dx, viewport.getOffsetY() + dy); 1525 this.slideMode_.applyViewportChange(); 1526 } else { 1527 // Traversing images by swipe. 1528 if (this.done_) 1529 break; 1530 var dx = 1531 event.touches[0].clientX - 1532 this.gestureStartEvent_.touches[0].clientX; 1533 if (dx > TouchHandler.SWIPE_THRESHOLD) { 1534 this.slideMode_.advanceManually(-1); 1535 this.done_ = true; 1536 } else if (dx < -TouchHandler.SWIPE_THRESHOLD) { 1537 this.slideMode_.advanceManually(1); 1538 this.done_ = true; 1539 } 1540 } 1541 break; 1542 1543 case 2: 1544 // Pinch zoom. 1545 var distance1 = TouchHandler.getDistance(this.lastEvent_); 1546 var distance2 = TouchHandler.getDistance(event); 1547 if (distance1 === 0) 1548 break; 1549 var zoom = distance2 / distance1 * this.lastZoom_; 1550 viewport.setZoom(zoom); 1551 1552 // Pinch rotation. 1553 var angle = TouchHandler.getTwistAngle(this.gestureStartEvent_, event); 1554 if (angle > TouchHandler.ROTATION_THRESHOLD) 1555 viewport.setRotation(this.gestureStartRotation_ + 1); 1556 else if (angle < -TouchHandler.ROTATION_THRESHOLD) 1557 viewport.setRotation(this.gestureStartRotation_ - 1); 1558 else 1559 viewport.setRotation(this.gestureStartRotation_); 1560 this.slideMode_.applyViewportChange(); 1561 break; 1562 } 1563 1564 // Update the last event. 1565 this.lastEvent_ = event; 1566 this.lastZoom_ = viewport.getZoom(); 1567}; 1568 1569/** 1570 * Handles mouse wheel events. 1571 * @param {MouseEvent} event Wheel event. 1572 * @private 1573 */ 1574TouchHandler.prototype.onMouseWheel_ = function(event) { 1575 var viewport = this.slideMode_.getViewport(); 1576 if (!this.enabled_ || !viewport.isZoomed()) 1577 return; 1578 this.stopOperation(); 1579 viewport.setOffset( 1580 viewport.getOffsetX() + event.wheelDeltaX, 1581 viewport.getOffsetY() + event.wheelDeltaY); 1582 this.slideMode_.applyViewportChange(); 1583}; 1584