Home | History | Annotate | Download | only in js
      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  * Called from the main frame when unloading.
      9  * @param {boolean=} opt_exiting True if the app is exiting.
     10  */
     11 function unload(opt_exiting) { Gallery.instance.onUnload(opt_exiting); }
     12 
     13 /**
     14  * Overrided metadata worker's path.
     15  * @type {string}
     16  * @const
     17  */
     18 ContentProvider.WORKER_SCRIPT = '/js/metadata_worker.js';
     19 
     20 /**
     21  * Data model for gallery.
     22  *
     23  * @param {MetadataCache} metadataCache Metadata cache.
     24  * @constructor
     25  * @extends {cr.ui.ArrayDataModel}
     26  */
     27 function GalleryDataModel(metadataCache) {
     28   cr.ui.ArrayDataModel.call(this, []);
     29 
     30   /**
     31    * Metadata cache.
     32    * @type {MetadataCache}
     33    * @private
     34    */
     35   this.metadataCache_ = metadataCache;
     36 
     37   /**
     38    * Directory where the image is saved if the image is located in a read-only
     39    * volume.
     40    * @type {DirectoryEntry}
     41    */
     42   this.fallbackSaveDirectory = null;
     43 }
     44 
     45 /**
     46  * Maximum number of full size image cache.
     47  * @type {number}
     48  * @const
     49  * @private
     50  */
     51 GalleryDataModel.MAX_FULL_IMAGE_CACHE_ = 3;
     52 
     53 /**
     54  * Maximum number of screen size image cache.
     55  * @type {number}
     56  * @const
     57  * @private
     58  */
     59 GalleryDataModel.MAX_SCREEN_IMAGE_CACHE_ = 5;
     60 
     61 GalleryDataModel.prototype = {
     62   __proto__: cr.ui.ArrayDataModel.prototype
     63 };
     64 
     65 /**
     66  * Saves new image.
     67  *
     68  * @param {VolumeManager} volumeManager Volume manager instance.
     69  * @param {Gallery.Item} item Original gallery item.
     70  * @param {Canvas} canvas Canvas containing new image.
     71  * @param {boolean} overwrite Whether to overwrite the image to the item or not.
     72  * @return {Promise} Promise to be fulfilled with when the operation completes.
     73  */
     74 GalleryDataModel.prototype.saveItem = function(
     75     volumeManager, item, canvas, overwrite) {
     76   var oldEntry = item.getEntry();
     77   var oldMetadata = item.getMetadata();
     78   var oldLocationInfo = item.getLocationInfo();
     79   var metadataEncoder = ImageEncoder.encodeMetadata(
     80       item.getMetadata(), canvas, 1 /* quality */);
     81   var newMetadata = ContentProvider.ConvertContentMetadata(
     82       metadataEncoder.getMetadata(),
     83       MetadataCache.cloneMetadata(item.getMetadata()));
     84   if (newMetadata.filesystem)
     85     newMetadata.filesystem.modificationTime = new Date();
     86   if (newMetadata.external)
     87     newMetadata.external.present = true;
     88 
     89   return new Promise(function(fulfill, reject) {
     90     item.saveToFile(
     91         volumeManager,
     92         this.fallbackSaveDirectory,
     93         overwrite,
     94         canvas,
     95         metadataEncoder,
     96         function(success) {
     97           if (!success) {
     98             reject('Failed to save the image.');
     99             return;
    100           }
    101 
    102           // The item's entry is updated to the latest entry. Update metadata.
    103           item.setMetadata(newMetadata);
    104 
    105           // Current entry is updated.
    106           // Dispatch an event.
    107           var event = new Event('content');
    108           event.item = item;
    109           event.oldEntry = oldEntry;
    110           event.metadata = newMetadata;
    111           this.dispatchEvent(event);
    112 
    113           if (util.isSameEntry(oldEntry, item.getEntry())) {
    114             // Need an update of metdataCache.
    115             this.metadataCache_.set(
    116                 item.getEntry(),
    117                 Gallery.METADATA_TYPE,
    118                 newMetadata);
    119           } else {
    120             // New entry is added and the item now tracks it.
    121             // Add another item for the old entry.
    122             var anotherItem = new Gallery.Item(
    123                 oldEntry,
    124                 oldLocationInfo,
    125                 oldMetadata,
    126                 this.metadataCache_,
    127                 item.isOriginal());
    128             // The item must be added behind the existing item so that it does
    129             // not change the index of the existing item.
    130             // TODO(hirono): Update the item index of the selection model
    131             // correctly.
    132             this.splice(this.indexOf(item) + 1, 0, anotherItem);
    133           }
    134 
    135           fulfill();
    136         }.bind(this));
    137   }.bind(this));
    138 };
    139 
    140 /**
    141  * Evicts image caches in the items.
    142  * @param {Gallery.Item} currentSelectedItem Current selected item.
    143  */
    144 GalleryDataModel.prototype.evictCache = function(currentSelectedItem) {
    145   // Sort the item by the last accessed date.
    146   var sorted = this.slice().sort(function(a, b) {
    147     return b.getLastAccessedDate() - a.getLastAccessedDate();
    148   });
    149 
    150   // Evict caches.
    151   var contentCacheCount = 0;
    152   var screenCacheCount = 0;
    153   for (var i = 0; i < sorted.length; i++) {
    154     if (sorted[i].contentImage) {
    155       if (++contentCacheCount > GalleryDataModel.MAX_FULL_IMAGE_CACHE_) {
    156         if (sorted[i].contentImage.parentNode) {
    157           console.error('The content image has a parent node.');
    158         } else {
    159           // Force to free the buffer of the canvas by assigning zero size.
    160           sorted[i].contentImage.width = 0;
    161           sorted[i].contentImage.height = 0;
    162           sorted[i].contentImage = null;
    163         }
    164       }
    165     }
    166     if (sorted[i].screenImage) {
    167       if (++screenCacheCount > GalleryDataModel.MAX_SCREEN_IMAGE_CACHE_) {
    168         if (sorted[i].screenImage.parentNode) {
    169           console.error('The screen image has a parent node.');
    170         } else {
    171           // Force to free the buffer of the canvas by assigning zero size.
    172           sorted[i].screenImage.width = 0;
    173           sorted[i].screenImage.height = 0;
    174           sorted[i].screenImage = null;
    175         }
    176       }
    177     }
    178   }
    179 };
    180 
    181 /**
    182  * Gallery for viewing and editing image files.
    183  *
    184  * @param {!VolumeManager} volumeManager The VolumeManager instance of the
    185  *     system.
    186  * @constructor
    187  */
    188 function Gallery(volumeManager) {
    189   this.context_ = {
    190     appWindow: chrome.app.window.current(),
    191     onClose: function() { close(); },
    192     onMaximize: function() {
    193       var appWindow = chrome.app.window.current();
    194       if (appWindow.isMaximized())
    195         appWindow.restore();
    196       else
    197         appWindow.maximize();
    198     },
    199     onMinimize: function() { chrome.app.window.current().minimize(); },
    200     onAppRegionChanged: function() {},
    201     metadataCache: MetadataCache.createFull(volumeManager),
    202     readonlyDirName: '',
    203     displayStringFunction: function() { return ''; },
    204     loadTimeData: {}
    205   };
    206   this.container_ = document.querySelector('.gallery');
    207   this.document_ = document;
    208   this.metadataCache_ = this.context_.metadataCache;
    209   this.volumeManager_ = volumeManager;
    210   this.selectedEntry_ = null;
    211   this.metadataCacheObserverId_ = null;
    212   this.onExternallyUnmountedBound_ = this.onExternallyUnmounted_.bind(this);
    213 
    214   this.dataModel_ = new GalleryDataModel(
    215       this.context_.metadataCache);
    216   var downloadVolumeInfo = this.volumeManager_.getCurrentProfileVolumeInfo(
    217       VolumeManagerCommon.VolumeType.DOWNLOADS);
    218   downloadVolumeInfo.resolveDisplayRoot().then(function(entry) {
    219     this.dataModel_.fallbackSaveDirectory = entry;
    220   }.bind(this)).catch(function(error) {
    221     console.error(
    222         'Failed to obtain the fallback directory: ' + (error.stack || error));
    223   });
    224   this.selectionModel_ = new cr.ui.ListSelectionModel();
    225 
    226   this.initDom_();
    227   this.initListeners_();
    228 }
    229 
    230 /**
    231  * Gallery extends cr.EventTarget.
    232  */
    233 Gallery.prototype.__proto__ = cr.EventTarget.prototype;
    234 
    235 /**
    236  * Tools fade-out timeout in milliseconds.
    237  * @const
    238  * @type {number}
    239  */
    240 Gallery.FADE_TIMEOUT = 3000;
    241 
    242 /**
    243  * First time tools fade-out timeout in milliseconds.
    244  * @const
    245  * @type {number}
    246  */
    247 Gallery.FIRST_FADE_TIMEOUT = 1000;
    248 
    249 /**
    250  * Time until mosaic is initialized in the background. Used to make gallery
    251  * in the slide mode load faster. In milliseconds.
    252  * @const
    253  * @type {number}
    254  */
    255 Gallery.MOSAIC_BACKGROUND_INIT_DELAY = 1000;
    256 
    257 /**
    258  * Types of metadata Gallery uses (to query the metadata cache).
    259  * @const
    260  * @type {string}
    261  */
    262 Gallery.METADATA_TYPE = 'thumbnail|filesystem|media|external';
    263 
    264 /**
    265  * Initializes listeners.
    266  * @private
    267  */
    268 Gallery.prototype.initListeners_ = function() {
    269   this.keyDownBound_ = this.onKeyDown_.bind(this);
    270   this.document_.body.addEventListener('keydown', this.keyDownBound_);
    271 
    272   this.inactivityWatcher_ = new MouseInactivityWatcher(
    273       this.container_, Gallery.FADE_TIMEOUT, this.hasActiveTool.bind(this));
    274 
    275   // Search results may contain files from different subdirectories so
    276   // the observer is not going to work.
    277   if (!this.context_.searchResults && this.context_.curDirEntry) {
    278     this.metadataCacheObserverId_ = this.metadataCache_.addObserver(
    279         this.context_.curDirEntry,
    280         MetadataCache.CHILDREN,
    281         'thumbnail',
    282         this.updateThumbnails_.bind(this));
    283   }
    284   this.volumeManager_.addEventListener(
    285       'externally-unmounted', this.onExternallyUnmountedBound_);
    286 };
    287 
    288 /**
    289  * Closes gallery when a volume containing the selected item is unmounted.
    290  * @param {!Event} event The unmount event.
    291  * @private
    292  */
    293 Gallery.prototype.onExternallyUnmounted_ = function(event) {
    294   if (!this.selectedEntry_)
    295     return;
    296 
    297   if (this.volumeManager_.getVolumeInfo(this.selectedEntry_) ===
    298       event.volumeInfo) {
    299     close();
    300   }
    301 };
    302 
    303 /**
    304  * Unloads the Gallery.
    305  * @param {boolean} exiting True if the app is exiting.
    306  */
    307 Gallery.prototype.onUnload = function(exiting) {
    308   if (this.metadataCacheObserverId_ !== null)
    309     this.metadataCache_.removeObserver(this.metadataCacheObserverId_);
    310   this.volumeManager_.removeEventListener(
    311       'externally-unmounted', this.onExternallyUnmountedBound_);
    312   this.slideMode_.onUnload(exiting);
    313 };
    314 
    315 /**
    316  * Initializes DOM UI
    317  * @private
    318  */
    319 Gallery.prototype.initDom_ = function() {
    320   // Initialize the dialog label.
    321   cr.ui.dialogs.BaseDialog.OK_LABEL = str('GALLERY_OK_LABEL');
    322   cr.ui.dialogs.BaseDialog.CANCEL_LABEL = str('GALLERY_CANCEL_LABEL');
    323 
    324   var content = document.querySelector('#content');
    325   content.addEventListener('click', this.onContentClick_.bind(this));
    326 
    327   this.header_ = document.querySelector('#header');
    328   this.toolbar_ = document.querySelector('#toolbar');
    329 
    330   var preventDefault = function(event) { event.preventDefault(); };
    331 
    332   var minimizeButton = util.createChild(this.header_,
    333                                         'minimize-button tool dimmable',
    334                                         'button');
    335   minimizeButton.tabIndex = -1;
    336   minimizeButton.addEventListener('click', this.onMinimize_.bind(this));
    337   minimizeButton.addEventListener('mousedown', preventDefault);
    338 
    339   var maximizeButton = util.createChild(this.header_,
    340                                         'maximize-button tool dimmable',
    341                                         'button');
    342   maximizeButton.tabIndex = -1;
    343   maximizeButton.addEventListener('click', this.onMaximize_.bind(this));
    344   maximizeButton.addEventListener('mousedown', preventDefault);
    345 
    346   var closeButton = util.createChild(this.header_,
    347                                      'close-button tool dimmable',
    348                                      'button');
    349   closeButton.tabIndex = -1;
    350   closeButton.addEventListener('click', this.onClose_.bind(this));
    351   closeButton.addEventListener('mousedown', preventDefault);
    352 
    353   this.filenameSpacer_ = this.toolbar_.querySelector('.filename-spacer');
    354   this.filenameEdit_ = util.createChild(this.filenameSpacer_,
    355                                         'namebox', 'input');
    356 
    357   this.filenameEdit_.setAttribute('type', 'text');
    358   this.filenameEdit_.addEventListener('blur',
    359       this.onFilenameEditBlur_.bind(this));
    360 
    361   this.filenameEdit_.addEventListener('focus',
    362       this.onFilenameFocus_.bind(this));
    363 
    364   this.filenameEdit_.addEventListener('keydown',
    365       this.onFilenameEditKeydown_.bind(this));
    366 
    367   var middleSpacer = this.filenameSpacer_ =
    368       this.toolbar_.querySelector('.middle-spacer');
    369   var buttonSpacer = this.toolbar_.querySelector('button-spacer');
    370 
    371   this.prompt_ = new ImageEditor.Prompt(this.container_, strf);
    372 
    373   this.modeButton_ = this.toolbar_.querySelector('button.mode');
    374   this.modeButton_.addEventListener('click', this.toggleMode_.bind(this, null));
    375 
    376   this.mosaicMode_ = new MosaicMode(content,
    377                                     this.dataModel_,
    378                                     this.selectionModel_,
    379                                     this.volumeManager_,
    380                                     this.toggleMode_.bind(this, null));
    381 
    382   this.slideMode_ = new SlideMode(this.container_,
    383                                   content,
    384                                   this.toolbar_,
    385                                   this.prompt_,
    386                                   this.dataModel_,
    387                                   this.selectionModel_,
    388                                   this.context_,
    389                                   this.volumeManager_,
    390                                   this.toggleMode_.bind(this),
    391                                   str);
    392 
    393   this.slideMode_.addEventListener('image-displayed', function() {
    394     cr.dispatchSimpleEvent(this, 'image-displayed');
    395   }.bind(this));
    396   this.slideMode_.addEventListener('image-saved', function() {
    397     cr.dispatchSimpleEvent(this, 'image-saved');
    398   }.bind(this));
    399 
    400   var deleteButton = this.initToolbarButton_('delete', 'GALLERY_DELETE');
    401   deleteButton.addEventListener('click', this.delete_.bind(this));
    402 
    403   this.shareButton_ = this.initToolbarButton_('share', 'GALLERY_SHARE');
    404   this.shareButton_.addEventListener(
    405       'click', this.onShareButtonClick_.bind(this));
    406 
    407   this.dataModel_.addEventListener('splice', this.onSplice_.bind(this));
    408   this.dataModel_.addEventListener('content', this.onContentChange_.bind(this));
    409 
    410   this.selectionModel_.addEventListener('change', this.onSelection_.bind(this));
    411   this.slideMode_.addEventListener('useraction', this.onUserAction_.bind(this));
    412 
    413   this.shareDialog_ = new ShareDialog(this.container_);
    414 };
    415 
    416 /**
    417  * Initializes a toolbar button.
    418  *
    419  * @param {string} className Class to add.
    420  * @param {string} title Button title.
    421  * @return {!HTMLElement} Newly created button.
    422  * @private
    423  */
    424 Gallery.prototype.initToolbarButton_ = function(className, title) {
    425   var button = this.toolbar_.querySelector('button.' + className);
    426   button.title = str(title);
    427   return button;
    428 };
    429 
    430 /**
    431  * Loads the content.
    432  *
    433  * @param {!Array.<Entry>} entries Array of entries.
    434  * @param {!Array.<Entry>} selectedEntries Array of selected entries.
    435  */
    436 Gallery.prototype.load = function(entries, selectedEntries) {
    437   // Obtains max chank size.
    438   var maxChunkSize = 20;
    439   var volumeInfo = this.volumeManager_.getVolumeInfo(entries[0]);
    440   if (volumeInfo &&
    441       volumeInfo.volumeType === VolumeManagerCommon.VolumeType.MTP) {
    442     maxChunkSize = 1;
    443   }
    444   if (volumeInfo.isReadOnly)
    445     this.context_.readonlyDirName = volumeInfo.label;
    446 
    447   // Make loading list.
    448   var entrySet = {};
    449   for (var i = 0; i < entries.length; i++) {
    450     var entry = entries[i];
    451     entrySet[entry.toURL()] = {
    452       entry: entry,
    453       selected: false,
    454       index: i
    455     };
    456   }
    457   for (var i = 0; i < selectedEntries.length; i++) {
    458     var entry = selectedEntries[i];
    459     entrySet[entry.toURL()] = {
    460       entry: entry,
    461       selected: true,
    462       index: i
    463     };
    464   }
    465   var loadingList = [];
    466   for (var url in entrySet) {
    467     loadingList.push(entrySet[url]);
    468   }
    469   loadingList = loadingList.sort(function(a, b) {
    470     if (a.selected && !b.selected)
    471       return -1;
    472     else if (!a.selected && b.selected)
    473       return 1;
    474     else
    475       return a.index - b.index;
    476   });
    477 
    478   // Load entries.
    479   // Use the self variable capture-by-closure because it is faster than bind.
    480   var self = this;
    481   var loadChunk = function(firstChunk) {
    482     // Extract chunk.
    483     var chunk = loadingList.splice(0, maxChunkSize);
    484     if (!chunk.length)
    485       return;
    486 
    487     return new Promise(function(fulfill) {
    488       // Obtains metadata for chunk.
    489       var entries = chunk.map(function(chunkItem) {
    490         return chunkItem.entry;
    491       });
    492       self.metadataCache_.get(entries, Gallery.METADATA_TYPE, fulfill);
    493     }).then(function(metadataList) {
    494       if (chunk.length !== metadataList.length)
    495         return Promise.reject('Failed to load metadata.');
    496 
    497       // Add items to the model.
    498       var items = [];
    499       chunk.forEach(function(chunkItem, index) {
    500         var locationInfo = self.volumeManager_.getLocationInfo(chunkItem.entry);
    501         if (!locationInfo)  // Skip the item, since gone.
    502           return;
    503         var clonedMetadata = MetadataCache.cloneMetadata(metadataList[index]);
    504         items.push(new Gallery.Item(
    505             chunkItem.entry,
    506             locationInfo,
    507             clonedMetadata,
    508             self.metadataCache_,
    509             /* original */ true));
    510       });
    511       self.dataModel_.push.apply(self.dataModel_, items);
    512 
    513       // Apply the selection.
    514       var selectionUpdated = false;
    515       for (var i = 0; i < chunk.length; i++) {
    516         if (!chunk[i].selected)
    517           continue;
    518         var index = self.dataModel_.indexOf(items[i]);
    519         if (index < 0)
    520           continue;
    521         self.selectionModel_.setIndexSelected(index, true);
    522         selectionUpdated = true;
    523       }
    524       if (selectionUpdated)
    525         self.onSelection_();
    526 
    527       // Init modes after the first chunk is loaded.
    528       if (firstChunk) {
    529         // Determine the initial mode.
    530         var shouldShowMosaic = selectedEntries.length > 1 ||
    531             (self.context_.pageState &&
    532              self.context_.pageState.gallery === 'mosaic');
    533         self.setCurrentMode_(
    534             shouldShowMosaic ? self.mosaicMode_ : self.slideMode_);
    535 
    536         // Init mosaic mode.
    537         var mosaic = self.mosaicMode_.getMosaic();
    538         mosaic.init();
    539 
    540         // Do the initialization for each mode.
    541         if (shouldShowMosaic) {
    542           mosaic.show();
    543           self.inactivityWatcher_.check();  // Show the toolbar.
    544           cr.dispatchSimpleEvent(self, 'loaded');
    545         } else {
    546           self.slideMode_.enter(
    547               null,
    548               function() {
    549                 // Flash the toolbar briefly to show it is there.
    550                 self.inactivityWatcher_.kick(Gallery.FIRST_FADE_TIMEOUT);
    551               },
    552               function() {
    553                 cr.dispatchSimpleEvent(self, 'loaded');
    554               });
    555         }
    556       }
    557 
    558       // Continue to load chunks.
    559       return loadChunk(/* firstChunk */ false);
    560     });
    561   };
    562   loadChunk(/* firstChunk */ true).catch(function(error) {
    563     console.error(error.stack || error);
    564   });
    565 };
    566 
    567 /**
    568  * Handles user's 'Close' action.
    569  * @private
    570  */
    571 Gallery.prototype.onClose_ = function() {
    572   this.executeWhenReady(this.context_.onClose);
    573 };
    574 
    575 /**
    576  * Handles user's 'Maximize' action (Escape or a click on the X icon).
    577  * @private
    578  */
    579 Gallery.prototype.onMaximize_ = function() {
    580   this.executeWhenReady(this.context_.onMaximize);
    581 };
    582 
    583 /**
    584  * Handles user's 'Maximize' action (Escape or a click on the X icon).
    585  * @private
    586  */
    587 Gallery.prototype.onMinimize_ = function() {
    588   this.executeWhenReady(this.context_.onMinimize);
    589 };
    590 
    591 /**
    592  * Executes a function when the editor is done with the modifications.
    593  * @param {function} callback Function to execute.
    594  */
    595 Gallery.prototype.executeWhenReady = function(callback) {
    596   this.currentMode_.executeWhenReady(callback);
    597 };
    598 
    599 /**
    600  * @return {Object} File manager private API.
    601  */
    602 Gallery.getFileManagerPrivate = function() {
    603   return chrome.fileManagerPrivate || window.top.chrome.fileManagerPrivate;
    604 };
    605 
    606 /**
    607  * @return {boolean} True if some tool is currently active.
    608  */
    609 Gallery.prototype.hasActiveTool = function() {
    610   return (this.currentMode_ && this.currentMode_.hasActiveTool()) ||
    611       this.isRenaming_();
    612 };
    613 
    614 /**
    615 * External user action event handler.
    616 * @private
    617 */
    618 Gallery.prototype.onUserAction_ = function() {
    619   // Show the toolbar and hide it after the default timeout.
    620   this.inactivityWatcher_.kick();
    621 };
    622 
    623 /**
    624  * Sets the current mode, update the UI.
    625  * @param {Object} mode Current mode.
    626  * @private
    627  */
    628 Gallery.prototype.setCurrentMode_ = function(mode) {
    629   if (mode !== this.slideMode_ && mode !== this.mosaicMode_)
    630     console.error('Invalid Gallery mode');
    631 
    632   this.currentMode_ = mode;
    633   this.container_.setAttribute('mode', this.currentMode_.getName());
    634   this.updateSelectionAndState_();
    635   this.updateButtons_();
    636 };
    637 
    638 /**
    639  * Mode toggle event handler.
    640  * @param {function=} opt_callback Callback.
    641  * @param {Event=} opt_event Event that caused this call.
    642  * @private
    643  */
    644 Gallery.prototype.toggleMode_ = function(opt_callback, opt_event) {
    645   if (!this.modeButton_)
    646     return;
    647 
    648   if (this.changingMode_) // Do not re-enter while changing the mode.
    649     return;
    650 
    651   if (opt_event)
    652     this.onUserAction_();
    653 
    654   this.changingMode_ = true;
    655 
    656   var onModeChanged = function() {
    657     this.changingMode_ = false;
    658     if (opt_callback) opt_callback();
    659   }.bind(this);
    660 
    661   var tileIndex = Math.max(0, this.selectionModel_.selectedIndex);
    662 
    663   var mosaic = this.mosaicMode_.getMosaic();
    664   var tileRect = mosaic.getTileRect(tileIndex);
    665 
    666   if (this.currentMode_ === this.slideMode_) {
    667     this.setCurrentMode_(this.mosaicMode_);
    668     mosaic.transform(
    669         tileRect, this.slideMode_.getSelectedImageRect(), true /* instant */);
    670     this.slideMode_.leave(
    671         tileRect,
    672         function() {
    673           // Animate back to normal position.
    674           mosaic.transform();
    675           mosaic.show();
    676           onModeChanged();
    677         }.bind(this));
    678   } else {
    679     this.setCurrentMode_(this.slideMode_);
    680     this.slideMode_.enter(
    681         tileRect,
    682         function() {
    683           // Animate to zoomed position.
    684           mosaic.transform(tileRect, this.slideMode_.getSelectedImageRect());
    685           mosaic.hide();
    686         }.bind(this),
    687         onModeChanged);
    688   }
    689 };
    690 
    691 /**
    692  * Deletes the selected items.
    693  * @private
    694  */
    695 Gallery.prototype.delete_ = function() {
    696   this.onUserAction_();
    697 
    698   // Clone the sorted selected indexes array.
    699   var indexesToRemove = this.selectionModel_.selectedIndexes.slice();
    700   if (!indexesToRemove.length)
    701     return;
    702 
    703   /* TODO(dgozman): Implement Undo delete, Remove the confirmation dialog. */
    704 
    705   var itemsToRemove = this.getSelectedItems();
    706   var plural = itemsToRemove.length > 1;
    707   var param = plural ? itemsToRemove.length : itemsToRemove[0].getFileName();
    708 
    709   function deleteNext() {
    710     if (!itemsToRemove.length)
    711       return;  // All deleted.
    712 
    713     var entry = itemsToRemove.pop().getEntry();
    714     entry.remove(deleteNext, function() {
    715       util.flog('Error deleting: ' + entry.name, deleteNext);
    716     });
    717   }
    718 
    719   // Prevent the Gallery from handling Esc and Enter.
    720   this.document_.body.removeEventListener('keydown', this.keyDownBound_);
    721   var restoreListener = function() {
    722     this.document_.body.addEventListener('keydown', this.keyDownBound_);
    723   }.bind(this);
    724 
    725 
    726   var confirm = new cr.ui.dialogs.ConfirmDialog(this.container_);
    727   confirm.setOkLabel(str('DELETE_BUTTON_LABEL'));
    728   confirm.show(strf(plural ?
    729       'GALLERY_CONFIRM_DELETE_SOME' : 'GALLERY_CONFIRM_DELETE_ONE', param),
    730       function() {
    731         restoreListener();
    732         this.selectionModel_.unselectAll();
    733         this.selectionModel_.leadIndex = -1;
    734         // Remove items from the data model, starting from the highest index.
    735         while (indexesToRemove.length)
    736           this.dataModel_.splice(indexesToRemove.pop(), 1);
    737         // Delete actual files.
    738         deleteNext();
    739       }.bind(this),
    740       function() {
    741         // Restore the listener after a timeout so that ESC is processed.
    742         setTimeout(restoreListener, 0);
    743       });
    744 };
    745 
    746 /**
    747  * @return {Array.<Gallery.Item>} Current selection.
    748  */
    749 Gallery.prototype.getSelectedItems = function() {
    750   return this.selectionModel_.selectedIndexes.map(
    751       this.dataModel_.item.bind(this.dataModel_));
    752 };
    753 
    754 /**
    755  * @return {Array.<Entry>} Array of currently selected entries.
    756  */
    757 Gallery.prototype.getSelectedEntries = function() {
    758   return this.selectionModel_.selectedIndexes.map(function(index) {
    759     return this.dataModel_.item(index).getEntry();
    760   }.bind(this));
    761 };
    762 
    763 /**
    764  * @return {?Gallery.Item} Current single selection.
    765  */
    766 Gallery.prototype.getSingleSelectedItem = function() {
    767   var items = this.getSelectedItems();
    768   if (items.length > 1) {
    769     console.error('Unexpected multiple selection');
    770     return null;
    771   }
    772   return items[0];
    773 };
    774 
    775 /**
    776   * Selection change event handler.
    777   * @private
    778   */
    779 Gallery.prototype.onSelection_ = function() {
    780   this.updateSelectionAndState_();
    781 };
    782 
    783 /**
    784   * Data model splice event handler.
    785   * @private
    786   */
    787 Gallery.prototype.onSplice_ = function() {
    788   this.selectionModel_.adjustLength(this.dataModel_.length);
    789 };
    790 
    791 /**
    792  * Content change event handler.
    793  * @param {Event} event Event.
    794  * @private
    795 */
    796 Gallery.prototype.onContentChange_ = function(event) {
    797   var index = this.dataModel_.indexOf(event.item);
    798   if (index !== this.selectionModel_.selectedIndex)
    799     console.error('Content changed for unselected item');
    800   this.updateSelectionAndState_();
    801 };
    802 
    803 /**
    804  * Keydown handler.
    805  *
    806  * @param {Event} event Event.
    807  * @private
    808  */
    809 Gallery.prototype.onKeyDown_ = function(event) {
    810   if (this.currentMode_.onKeyDown(event))
    811     return;
    812 
    813   switch (util.getKeyModifiers(event) + event.keyIdentifier) {
    814     case 'U+0008': // Backspace.
    815       // The default handler would call history.back and close the Gallery.
    816       event.preventDefault();
    817       break;
    818 
    819     case 'U+004D':  // 'm' switches between Slide and Mosaic mode.
    820       this.toggleMode_(null, event);
    821       break;
    822 
    823     case 'U+0056':  // 'v'
    824     case 'MediaPlayPause':
    825       this.slideMode_.startSlideshow(SlideMode.SLIDESHOW_INTERVAL_FIRST, event);
    826       break;
    827 
    828     case 'U+007F':  // Delete
    829     case 'Shift-U+0033':  // Shift+'3' (Delete key might be missing).
    830     case 'U+0044':  // 'd'
    831       this.delete_();
    832       break;
    833   }
    834 };
    835 
    836 // Name box and rename support.
    837 
    838 /**
    839  * Updates the UI related to the selected item and the persistent state.
    840  *
    841  * @private
    842  */
    843 Gallery.prototype.updateSelectionAndState_ = function() {
    844   var numSelectedItems = this.selectionModel_.selectedIndexes.length;
    845   var selectedEntryURL = null;
    846 
    847   // If it's selecting something, update the variable values.
    848   if (numSelectedItems) {
    849     // Obtains selected item.
    850     var selectedItem =
    851         this.dataModel_.item(this.selectionModel_.selectedIndex);
    852     this.selectedEntry_ = selectedItem.getEntry();
    853     selectedEntryURL = this.selectedEntry_.toURL();
    854 
    855     // Update cache.
    856     selectedItem.touch();
    857     this.dataModel_.evictCache();
    858 
    859     // Update the title and the display name.
    860     if (numSelectedItems === 1) {
    861       document.title = this.selectedEntry_.name;
    862       this.filenameEdit_.disabled = selectedItem.getLocationInfo().isReadOnly;
    863       this.filenameEdit_.value =
    864           ImageUtil.getDisplayNameFromName(this.selectedEntry_.name);
    865       this.shareButton_.hidden = !selectedItem.getLocationInfo().isDriveBased;
    866     } else {
    867       if (this.context_.curDirEntry) {
    868         // If the Gallery was opened on search results the search query will not
    869         // be recorded in the app state and the relaunch will just open the
    870         // gallery in the curDirEntry directory.
    871         document.title = this.context_.curDirEntry.name;
    872       } else {
    873         document.title = '';
    874       }
    875       this.filenameEdit_.disabled = true;
    876       this.filenameEdit_.value =
    877           strf('GALLERY_ITEMS_SELECTED', numSelectedItems);
    878       this.shareButton_.hidden = true;
    879     }
    880   } else {
    881     document.title = '';
    882     this.filenameEdit_.disabled = true;
    883     this.filenameEdit_.value = '';
    884     this.shareButton_.hidden = true;
    885   }
    886 
    887   util.updateAppState(
    888       null,  // Keep the current directory.
    889       selectedEntryURL,  // Update the selection.
    890       {gallery: (this.currentMode_ === this.mosaicMode_ ? 'mosaic' : 'slide')});
    891 };
    892 
    893 /**
    894  * Click event handler on filename edit box
    895  * @private
    896  */
    897 Gallery.prototype.onFilenameFocus_ = function() {
    898   ImageUtil.setAttribute(this.filenameSpacer_, 'renaming', true);
    899   this.filenameEdit_.originalValue = this.filenameEdit_.value;
    900   setTimeout(this.filenameEdit_.select.bind(this.filenameEdit_), 0);
    901   this.onUserAction_();
    902 };
    903 
    904 /**
    905  * Blur event handler on filename edit box.
    906  *
    907  * @param {Event} event Blur event.
    908  * @return {Promise} Promise fulfilled on renaming completed.
    909  * @private
    910  */
    911 Gallery.prototype.onFilenameEditBlur_ = function(event) {
    912   var item = this.getSingleSelectedItem();
    913   if (item) {
    914     var oldEntry = item.getEntry();
    915 
    916     item.rename(this.filenameEdit_.value).then(function() {
    917       var event = new Event('content');
    918       event.item = item;
    919       event.oldEntry = oldEntry;
    920       event.metadata = null;  // Metadata unchanged.
    921       this.dataModel_.dispatchEvent(event);
    922     }.bind(this), function(error) {
    923       if (error === 'NOT_CHANGED')
    924         return Promise.resolve();
    925       this.filenameEdit_.value =
    926           ImageUtil.getDisplayNameFromName(item.getEntry().name);
    927       this.filenameEdit_.focus();
    928       if (typeof error === 'string')
    929         this.prompt_.showStringAt('center', error, 5000);
    930       else
    931         return Promise.reject(error);
    932     }.bind(this)).catch(function(error) {
    933       console.error(error.stack || error);
    934     });
    935   }
    936 
    937   ImageUtil.setAttribute(this.filenameSpacer_, 'renaming', false);
    938   this.onUserAction_();
    939   return Promise.resolve();
    940 };
    941 
    942 /**
    943  * Keydown event handler on filename edit box
    944  * @private
    945  */
    946 Gallery.prototype.onFilenameEditKeydown_ = function() {
    947   switch (event.keyCode) {
    948     case 27:  // Escape
    949       this.filenameEdit_.value = this.filenameEdit_.originalValue;
    950       this.filenameEdit_.blur();
    951       break;
    952 
    953     case 13:  // Enter
    954       this.filenameEdit_.blur();
    955       break;
    956   }
    957   event.stopPropagation();
    958 };
    959 
    960 /**
    961  * @return {boolean} True if file renaming is currently in progress.
    962  * @private
    963  */
    964 Gallery.prototype.isRenaming_ = function() {
    965   return this.filenameSpacer_.hasAttribute('renaming');
    966 };
    967 
    968 /**
    969  * Content area click handler.
    970  * @private
    971  */
    972 Gallery.prototype.onContentClick_ = function() {
    973   this.filenameEdit_.blur();
    974 };
    975 
    976 /**
    977  * Share button handler.
    978  * @private
    979  */
    980 Gallery.prototype.onShareButtonClick_ = function() {
    981   var item = this.getSingleSelectedItem();
    982   if (!item)
    983     return;
    984   this.shareDialog_.show(item.getEntry(), function() {});
    985 };
    986 
    987 /**
    988  * Updates thumbnails.
    989  * @private
    990  */
    991 Gallery.prototype.updateThumbnails_ = function() {
    992   if (this.currentMode_ === this.slideMode_)
    993     this.slideMode_.updateThumbnails();
    994 
    995   if (this.mosaicMode_) {
    996     var mosaic = this.mosaicMode_.getMosaic();
    997     if (mosaic.isInitialized())
    998       mosaic.reload();
    999   }
   1000 };
   1001 
   1002 /**
   1003  * Updates buttons.
   1004  * @private
   1005  */
   1006 Gallery.prototype.updateButtons_ = function() {
   1007   if (this.modeButton_) {
   1008     var oppositeMode =
   1009         this.currentMode_ === this.slideMode_ ? this.mosaicMode_ :
   1010                                                 this.slideMode_;
   1011     this.modeButton_.title = str(oppositeMode.getTitle());
   1012   }
   1013 };
   1014 
   1015 /**
   1016  * Singleton gallery.
   1017  * @type {Gallery}
   1018  */
   1019 var gallery = null;
   1020 
   1021 /**
   1022  * Initialize the window.
   1023  * @param {Object} backgroundComponents Background components.
   1024  */
   1025 window.initialize = function(backgroundComponents) {
   1026   window.loadTimeData.data = backgroundComponents.stringData;
   1027   gallery = new Gallery(backgroundComponents.volumeManager);
   1028 };
   1029 
   1030 /**
   1031  * Loads entries.
   1032  * @param {!Array.<Entry>} entries Array of entries.
   1033  * @param {!Array.<Entry>} selectedEntries Array of selected entries.
   1034  */
   1035 window.loadEntries = function(entries, selectedEntries) {
   1036   gallery.load(entries, selectedEntries);
   1037 };
   1038