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