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 */
11function unload(opt_exiting) { Gallery.instance.onUnload(opt_exiting); }
12
13/**
14 * Overrided metadata worker's path.
15 * @type {string}
16 * @const
17 */
18ContentProvider.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 */
27function 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 */
51GalleryDataModel.MAX_FULL_IMAGE_CACHE_ = 3;
52
53/**
54 * Maximum number of screen size image cache.
55 * @type {number}
56 * @const
57 * @private
58 */
59GalleryDataModel.MAX_SCREEN_IMAGE_CACHE_ = 5;
60
61GalleryDataModel.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 */
74GalleryDataModel.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 */
144GalleryDataModel.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 */
188function 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 */
233Gallery.prototype.__proto__ = cr.EventTarget.prototype;
234
235/**
236 * Tools fade-out timeout in milliseconds.
237 * @const
238 * @type {number}
239 */
240Gallery.FADE_TIMEOUT = 3000;
241
242/**
243 * First time tools fade-out timeout in milliseconds.
244 * @const
245 * @type {number}
246 */
247Gallery.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 */
255Gallery.MOSAIC_BACKGROUND_INIT_DELAY = 1000;
256
257/**
258 * Types of metadata Gallery uses (to query the metadata cache).
259 * @const
260 * @type {string}
261 */
262Gallery.METADATA_TYPE = 'thumbnail|filesystem|media|external';
263
264/**
265 * Initializes listeners.
266 * @private
267 */
268Gallery.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 */
293Gallery.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 */
307Gallery.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 */
319Gallery.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 */
424Gallery.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 */
436Gallery.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 */
571Gallery.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 */
579Gallery.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 */
587Gallery.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 */
595Gallery.prototype.executeWhenReady = function(callback) {
596  this.currentMode_.executeWhenReady(callback);
597};
598
599/**
600 * @return {Object} File manager private API.
601 */
602Gallery.getFileManagerPrivate = function() {
603  return chrome.fileManagerPrivate || window.top.chrome.fileManagerPrivate;
604};
605
606/**
607 * @return {boolean} True if some tool is currently active.
608 */
609Gallery.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*/
618Gallery.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 */
628Gallery.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 */
644Gallery.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 */
695Gallery.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 */
749Gallery.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 */
757Gallery.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 */
766Gallery.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  */
779Gallery.prototype.onSelection_ = function() {
780  this.updateSelectionAndState_();
781};
782
783/**
784  * Data model splice event handler.
785  * @private
786  */
787Gallery.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*/
796Gallery.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 */
809Gallery.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 */
843Gallery.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 */
897Gallery.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 */
911Gallery.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 */
946Gallery.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 */
964Gallery.prototype.isRenaming_ = function() {
965  return this.filenameSpacer_.hasAttribute('renaming');
966};
967
968/**
969 * Content area click handler.
970 * @private
971 */
972Gallery.prototype.onContentClick_ = function() {
973  this.filenameEdit_.blur();
974};
975
976/**
977 * Share button handler.
978 * @private
979 */
980Gallery.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 */
991Gallery.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 */
1006Gallery.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 */
1019var gallery = null;
1020
1021/**
1022 * Initialize the window.
1023 * @param {Object} backgroundComponents Background components.
1024 */
1025window.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 */
1035window.loadEntries = function(entries, selectedEntries) {
1036  gallery.load(entries, selectedEntries);
1037};
1038