1// Copyright (c) 2012 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 * Global (placed in the window object) variable name to hold internal
9 * file dragging information. Needed to show visual feedback while dragging
10 * since DataTransfer object is in protected state. Reachable from other
11 * file manager instances.
12 */
13var DRAG_AND_DROP_GLOBAL_DATA = '__drag_and_drop_global_data';
14
15/**
16 * @param {HTMLDocument} doc Owning document.
17 * @param {FileOperationManager} fileOperationManager File operation manager
18 *     instance.
19 * @param {MetadataCache} metadataCache Metadata cache service.
20 * @param {DirectoryModel} directoryModel Directory model instance.
21 * @param {VolumeManagerWrapper} volumeManager Volume manager instance.
22 * @param {MultiProfileShareDialog} multiProfileShareDialog Share dialog to be
23 *     used to share files from another profile.
24 * @param {ProgressCenter} progressCenter To notify starting copy operation.
25 * @constructor
26 */
27function FileTransferController(doc,
28                                fileOperationManager,
29                                metadataCache,
30                                directoryModel,
31                                volumeManager,
32                                multiProfileShareDialog,
33                                progressCenter) {
34  this.document_ = doc;
35  this.fileOperationManager_ = fileOperationManager;
36  this.metadataCache_ = metadataCache;
37  this.directoryModel_ = directoryModel;
38  this.volumeManager_ = volumeManager;
39  this.multiProfileShareDialog_ = multiProfileShareDialog;
40  this.progressCenter_ = progressCenter;
41
42  this.directoryModel_.getFileList().addEventListener(
43      'change',
44      function(event) {
45        if (this.directoryModel_.getFileListSelection().
46            getIndexSelected(event.index)) {
47          this.onSelectionChanged_();
48        }
49      }.bind(this));
50  this.directoryModel_.getFileListSelection().addEventListener('change',
51      this.onSelectionChanged_.bind(this));
52
53  /**
54   * The array of pending task ID.
55   * @type {Array.<string>}
56   */
57  this.pendingTaskIds = [];
58
59  /**
60   * Promise to be fulfilled with the thumbnail image of selected file in drag
61   * operation. Used if only one element is selected.
62   * @type {Promise}
63   * @private
64   */
65  this.preloadedThumbnailImagePromise_ = null;
66
67  /**
68   * File objects for selected files.
69   *
70   * @type {Array.<File>}
71   * @private
72   */
73  this.selectedFileObjects_ = [];
74
75  /**
76   * Drag selector.
77   * @type {DragSelector}
78   * @private
79   */
80  this.dragSelector_ = new DragSelector();
81
82  /**
83   * Whether a user is touching the device or not.
84   * @type {boolean}
85   * @private
86   */
87  this.touching_ = false;
88
89  /**
90   * Task ID counter.
91   * @type {number}
92   * @private
93   */
94  this.taskIdCounter_ = 0;
95}
96
97/**
98 * Size of drag thumbnail for image files.
99 *
100 * @type {number}
101 * @const
102 * @private
103 */
104FileTransferController.DRAG_THUMBNAIL_SIZE_ = 64;
105
106FileTransferController.prototype = {
107  __proto__: cr.EventTarget.prototype,
108
109  /**
110   * @this {FileTransferController}
111   * @param {cr.ui.List} list Items in the list will be draggable.
112   */
113  attachDragSource: function(list) {
114    list.style.webkitUserDrag = 'element';
115    list.addEventListener('dragstart', this.onDragStart_.bind(this, list));
116    list.addEventListener('dragend', this.onDragEnd_.bind(this, list));
117    list.addEventListener('touchstart', this.onTouchStart_.bind(this));
118    list.ownerDocument.addEventListener(
119        'touchend', this.onTouchEnd_.bind(this), true);
120    list.ownerDocument.addEventListener(
121        'touchcancel', this.onTouchEnd_.bind(this), true);
122  },
123
124  /**
125   * @this {FileTransferController}
126   * @param {cr.ui.List} list List itself and its directory items will could
127   *                          be drop target.
128   * @param {boolean=} opt_onlyIntoDirectories If true only directory list
129   *     items could be drop targets. Otherwise any other place of the list
130   *     accetps files (putting it into the current directory).
131   */
132  attachFileListDropTarget: function(list, opt_onlyIntoDirectories) {
133    list.addEventListener('dragover', this.onDragOver_.bind(this,
134        !!opt_onlyIntoDirectories, list));
135    list.addEventListener('dragenter',
136        this.onDragEnterFileList_.bind(this, list));
137    list.addEventListener('dragleave', this.onDragLeave_.bind(this, list));
138    list.addEventListener('drop',
139        this.onDrop_.bind(this, !!opt_onlyIntoDirectories));
140  },
141
142  /**
143   * @this {FileTransferController}
144   * @param {DirectoryTree} tree Its sub items will could be drop target.
145   */
146  attachTreeDropTarget: function(tree) {
147    tree.addEventListener('dragover', this.onDragOver_.bind(this, true, tree));
148    tree.addEventListener('dragenter', this.onDragEnterTree_.bind(this, tree));
149    tree.addEventListener('dragleave', this.onDragLeave_.bind(this, tree));
150    tree.addEventListener('drop', this.onDrop_.bind(this, true));
151  },
152
153  /**
154   * Attach handlers of copy, cut and paste operations to the document.
155   *
156   * @this {FileTransferController}
157   */
158  attachCopyPasteHandlers: function() {
159    this.document_.addEventListener('beforecopy',
160                                    this.onBeforeCopy_.bind(this));
161    this.document_.addEventListener('copy',
162                                    this.onCopy_.bind(this));
163    this.document_.addEventListener('beforecut',
164                                    this.onBeforeCut_.bind(this));
165    this.document_.addEventListener('cut',
166                                    this.onCut_.bind(this));
167    this.document_.addEventListener('beforepaste',
168                                    this.onBeforePaste_.bind(this));
169    this.document_.addEventListener('paste',
170                                    this.onPaste_.bind(this));
171    this.copyCommand_ = this.document_.querySelector('command#copy');
172  },
173
174  /**
175   * Write the current selection to system clipboard.
176   *
177   * @this {FileTransferController}
178   * @param {DataTransfer} dataTransfer DataTransfer from the event.
179   * @param {string} effectAllowed Value must be valid for the
180   *     |dataTransfer.effectAllowed| property.
181   */
182  cutOrCopy_: function(dataTransfer, effectAllowed) {
183    // Existence of the volumeInfo is checked in canXXX methods.
184    var volumeInfo = this.volumeManager_.getVolumeInfo(
185        this.currentDirectoryContentEntry);
186    // Tag to check it's filemanager data.
187    dataTransfer.setData('fs/tag', 'filemanager-data');
188    dataTransfer.setData('fs/sourceRootURL',
189                         volumeInfo.fileSystem.root.toURL());
190    var sourceURLs = util.entriesToURLs(this.selectedEntries_);
191    dataTransfer.setData('fs/sources', sourceURLs.join('\n'));
192    dataTransfer.effectAllowed = effectAllowed;
193    dataTransfer.setData('fs/effectallowed', effectAllowed);
194    dataTransfer.setData('fs/missingFileContents',
195                         !this.isAllSelectedFilesAvailable_());
196
197    for (var i = 0; i < this.selectedFileObjects_.length; i++) {
198      dataTransfer.items.add(this.selectedFileObjects_[i]);
199    }
200  },
201
202  /**
203   * @this {FileTransferController}
204   * @return {Object.<string, string>} Drag and drop global data object.
205   */
206  getDragAndDropGlobalData_: function() {
207    if (window[DRAG_AND_DROP_GLOBAL_DATA])
208      return window[DRAG_AND_DROP_GLOBAL_DATA];
209
210    // Dragging from other tabs/windows.
211    var views = chrome && chrome.extension ? chrome.extension.getViews() : [];
212    for (var i = 0; i < views.length; i++) {
213      if (views[i][DRAG_AND_DROP_GLOBAL_DATA])
214        return views[i][DRAG_AND_DROP_GLOBAL_DATA];
215    }
216    return null;
217  },
218
219  /**
220   * Extracts source root URL from the |dataTransfer| object.
221   *
222   * @this {FileTransferController}
223   * @param {DataTransfer} dataTransfer DataTransfer object from the event.
224   * @return {string} URL or an empty string (if unknown).
225   */
226  getSourceRootURL_: function(dataTransfer) {
227    var sourceRootURL = dataTransfer.getData('fs/sourceRootURL');
228    if (sourceRootURL)
229      return sourceRootURL;
230
231    // |dataTransfer| in protected mode.
232    var globalData = this.getDragAndDropGlobalData_();
233    if (globalData)
234      return globalData.sourceRootURL;
235
236    // Unknown source.
237    return '';
238  },
239
240  /**
241   * @this {FileTransferController}
242   * @param {DataTransfer} dataTransfer DataTransfer object from the event.
243   * @return {boolean} Returns true when missing some file contents.
244   */
245  isMissingFileContents_: function(dataTransfer) {
246    var data = dataTransfer.getData('fs/missingFileContents');
247    if (!data) {
248      // |dataTransfer| in protected mode.
249      var globalData = this.getDragAndDropGlobalData_();
250      if (globalData)
251        data = globalData.missingFileContents;
252    }
253    return data === 'true';
254  },
255
256  /**
257   * Obtains entries that need to share with me.
258   * The method also observers child entries of the given entries.
259   * @param {Array.<Entries>} entries Entries.
260   * @return {Promise} Promise to be fulfilled with the entries that need to
261   *     share.
262   */
263  getMultiProfileShareEntries_: function(entries) {
264    // Utility function to concat arrays.
265    var concatArrays = function(arrays) {
266      return Array.prototype.concat.apply([], arrays);
267    };
268
269    // Call processEntry for each item of entries.
270    var processEntries = function(entries) {
271      var files = entries.filter(function(entry) {return entry.isFile;});
272      var dirs = entries.filter(function(entry) {return !entry.isFile;});
273      var promises = dirs.map(processDirectoryEntry);
274      if (files.length > 0)
275        promises.push(processFileEntries(files));
276      return Promise.all(promises).then(concatArrays);
277    };
278
279    // Check all file entries and keeps only those need sharing operation.
280    var processFileEntries = function(entries) {
281      return new Promise(function(callback) {
282        // TODO(mtomasz): Move conversion from entry to url to custom bindings.
283        // crbug.com/345527.
284        var urls = util.entriesToURLs(entries);
285        chrome.fileManagerPrivate.getEntryProperties(urls, callback);
286      }).then(function(metadatas) {
287        return entries.filter(function(entry, i) {
288          var metadata = metadatas[i];
289          return metadata && metadata.isHosted && !metadata.sharedWithMe;
290        });
291      });
292    };
293
294    // Check child entries.
295    var processDirectoryEntry = function(entry) {
296      return readEntries(entry.createReader());
297    };
298
299    // Read entries from DirectoryReader and call processEntries for the chunk
300    // of entries.
301    var readEntries = function(reader) {
302      return new Promise(reader.readEntries.bind(reader)).then(
303          function(entries) {
304            if (entries.length > 0) {
305              return Promise.all(
306                  [processEntries(entries), readEntries(reader)]).
307                  then(concatArrays);
308            } else {
309              return [];
310            }
311          },
312          function(error) {
313            console.warn(
314                'Error happens while reading directory.', error);
315            return [];
316          });
317    }.bind(this);
318
319    // Filter entries that is owned by the current user, and call
320    // processEntries.
321    return processEntries(entries.filter(function(entry) {
322      // If the volumeInfo is found, the entry belongs to the current user.
323      return !this.volumeManager_.getVolumeInfo(entry);
324    }.bind(this)));
325  },
326
327  /**
328   * Queue up a file copy operation based on the current system clipboard.
329   *
330   * @this {FileTransferController}
331   * @param {DataTransfer} dataTransfer System data transfer object.
332   * @param {DirectoryEntry=} opt_destinationEntry Paste destination.
333   * @param {string=} opt_effect Desired drop/paste effect. Could be
334   *     'move'|'copy' (default is copy). Ignored if conflicts with
335   *     |dataTransfer.effectAllowed|.
336   * @return {string} Either "copy" or "move".
337   */
338  paste: function(dataTransfer, opt_destinationEntry, opt_effect) {
339    var sourceURLs = dataTransfer.getData('fs/sources') ?
340        dataTransfer.getData('fs/sources').split('\n') : [];
341    // effectAllowed set in copy/paste handlers stay uninitialized. DnD handlers
342    // work fine.
343    var effectAllowed = dataTransfer.effectAllowed !== 'uninitialized' ?
344        dataTransfer.effectAllowed : dataTransfer.getData('fs/effectallowed');
345    var toMove = util.isDropEffectAllowed(effectAllowed, 'move') &&
346        (!util.isDropEffectAllowed(effectAllowed, 'copy') ||
347         opt_effect === 'move');
348    var destinationEntry =
349        opt_destinationEntry || this.currentDirectoryContentEntry;
350    var entries = [];
351    var failureUrls;
352    var taskId = this.fileOperationManager_.generateTaskId();
353
354    util.URLsToEntries(sourceURLs).then(function(result) {
355      failureUrls = result.failureUrls;
356      return this.fileOperationManager_.filterSameDirectoryEntry(
357          result.entries, destinationEntry, toMove);
358    }.bind(this)).then(function(filteredEntries) {
359      entries = filteredEntries;
360      if (entries.length === 0)
361        return Promise.reject('ABORT');
362
363      this.pendingTaskIds.push(taskId);
364      var item = new ProgressCenterItem();
365      item.id = taskId;
366      if (toMove) {
367        item.type = ProgressItemType.MOVE;
368        if (entries.length === 1)
369          item.message = strf('MOVE_FILE_NAME', entries[0].name);
370        else
371          item.message = strf('MOVE_ITEMS_REMAINING', entries.length);
372      } else {
373        item.type = ProgressItemType.COPY;
374        if (entries.length === 1)
375          item.message = strf('COPY_FILE_NAME', entries[0].name);
376        else
377          item.message = strf('COPY_ITEMS_REMAINING', entries.length);
378      }
379      this.progressCenter_.updateItem(item);
380      // Check if cross share is needed or not.
381      return this.getMultiProfileShareEntries_(entries);
382    }.bind(this)).then(function(shareEntries) {
383      if (shareEntries.length === 0)
384        return;
385      return this.multiProfileShareDialog_.show(shareEntries.length > 1).
386          then(function(dialogResult) {
387            if (dialogResult === 'cancel')
388              return Promise.reject('ABORT');
389            // Do cross share.
390            // TODO(hirono): Make the loop cancellable.
391            var requestDriveShare = function(index) {
392              if (index >= shareEntries.length)
393                return;
394              return new Promise(function(fulfill) {
395                chrome.fileManagerPrivate.requestDriveShare(
396                    shareEntries[index].toURL(),
397                    dialogResult,
398                    function() {
399                      // TODO(hirono): Check chrome.runtime.lastError here.
400                      fulfill();
401                    });
402              }).then(requestDriveShare.bind(null, index + 1));
403            };
404            return requestDriveShare(0);
405          });
406    }.bind(this)).then(function() {
407      // Start the pasting operation.
408      this.fileOperationManager_.paste(
409          entries, destinationEntry, toMove, taskId);
410      this.pendingTaskIds.splice(this.pendingTaskIds.indexOf(taskId), 1);
411
412      // Publish events for failureUrls.
413      for (var i = 0; i < failureUrls.length; i++) {
414        var fileName =
415            decodeURIComponent(failureUrls[i].replace(/^.+\//, ''));
416        var event = new Event('source-not-found');
417        event.fileName = fileName;
418        event.progressType =
419            toMove ? ProgressItemType.MOVE : ProgressItemType.COPY;
420        this.dispatchEvent(event);
421      }
422    }.bind(this)).catch(function(error) {
423      if (error !== 'ABORT')
424        console.error(error.stack ? error.stack : error);
425    });
426    return toMove ? 'move' : 'copy';
427  },
428
429  /**
430   * Preloads an image thumbnail for the specified file entry.
431   *
432   * @this {FileTransferController}
433   * @param {Entry} entry Entry to preload a thumbnail for.
434   */
435  preloadThumbnailImage_: function(entry) {
436    var metadataPromise = new Promise(function(fulfill, reject) {
437      this.metadataCache_.getOne(
438          entry,
439          'thumbnail|filesystem',
440          function(metadata) {
441            if (metadata)
442              fulfill(metadata);
443            else
444              reject('Failed to fetch metadata.');
445          });
446    }.bind(this));
447
448    var imagePromise = metadataPromise.then(function(metadata) {
449      return new Promise(function(fulfill, reject) {
450        var loader = new ThumbnailLoader(
451            entry, ThumbnailLoader.LoaderType.Image, metadata);
452        loader.loadDetachedImage(function(result) {
453          if (result)
454            fulfill(loader.getImage());
455        });
456      });
457    });
458
459    imagePromise.then(function(image) {
460      // Store the image so that we can obtain the image synchronously.
461      imagePromise.value = image;
462    }, function(error) {
463      console.error(error.stack || error);
464    });
465
466    this.preloadedThumbnailImagePromise_ = imagePromise;
467  },
468
469  /**
470   * Renders a drag-and-drop thumbnail.
471   *
472   * @this {FileTransferController}
473   * @return {HTMLElement} Element containing the thumbnail.
474   */
475  renderThumbnail_: function() {
476    var length = this.selectedEntries_.length;
477
478    var container = this.document_.querySelector('#drag-container');
479    var contents = this.document_.createElement('div');
480    contents.className = 'drag-contents';
481    container.appendChild(contents);
482
483    // Option 1. Multiple selection, render only a label.
484    if (length > 1) {
485      var label = this.document_.createElement('div');
486      label.className = 'label';
487      label.textContent = strf('DRAGGING_MULTIPLE_ITEMS', length);
488      contents.appendChild(label);
489      return container;
490    }
491
492    // Option 2. Thumbnail image available, then render it without
493    // a label.
494    if (this.preloadedThumbnailImagePromise_ &&
495        this.preloadedThumbnailImagePromise_.value) {
496      var thumbnailImage = this.preloadedThumbnailImagePromise_.value;
497
498      // Resize the image to canvas.
499      var canvas = document.createElement('canvas');
500      canvas.width = FileTransferController.DRAG_THUMBNAIL_SIZE_;
501      canvas.height = FileTransferController.DRAG_THUMBNAIL_SIZE_;
502
503      var minScale = Math.min(
504          thumbnailImage.width / canvas.width,
505          thumbnailImage.height / canvas.height);
506      var srcWidth = Math.min(canvas.width * minScale, thumbnailImage.width);
507      var srcHeight = Math.min(canvas.height * minScale, thumbnailImage.height);
508
509      var context = canvas.getContext('2d');
510      context.drawImage(thumbnailImage,
511                        (thumbnailImage.width - srcWidth) / 2,
512                        (thumbnailImage.height - srcHeight) / 2,
513                        srcWidth,
514                        srcHeight,
515                        0,
516                        0,
517                        canvas.width,
518                        canvas.height);
519      contents.classList.add('for-image');
520      contents.appendChild(canvas);
521      return container;
522    }
523
524    // Option 3. Thumbnail not available. Render an icon and a label.
525    var entry = this.selectedEntries_[0];
526    var icon = this.document_.createElement('div');
527    icon.className = 'detail-icon';
528    icon.setAttribute('file-type-icon', FileType.getIcon(entry));
529    contents.appendChild(icon);
530    var label = this.document_.createElement('div');
531    label.className = 'label';
532    label.textContent = entry.name;
533    contents.appendChild(label);
534    return container;
535  },
536
537  /**
538   * @this {FileTransferController}
539   * @param {cr.ui.List} list Drop target list
540   * @param {Event} event A dragstart event of DOM.
541   */
542  onDragStart_: function(list, event) {
543    // Check if a drag selection should be initiated or not.
544    if (list.shouldStartDragSelection(event)) {
545      event.preventDefault();
546      // If this drag operation is initiated by mouse, start selecting area.
547      if (!this.touching_)
548        this.dragSelector_.startDragSelection(list, event);
549      return;
550    }
551
552    // Nothing selected.
553    if (!this.selectedEntries_.length) {
554      event.preventDefault();
555      return;
556    }
557
558    var dt = event.dataTransfer;
559    var canCopy = this.canCopyOrDrag_(dt);
560    var canCut = this.canCutOrDrag_(dt);
561    if (canCopy || canCut) {
562      if (canCopy && canCut) {
563        this.cutOrCopy_(dt, 'all');
564      } else if (canCopy) {
565        this.cutOrCopy_(dt, 'copyLink');
566      } else {
567        this.cutOrCopy_(dt, 'move');
568      }
569    } else {
570      event.preventDefault();
571      return;
572    }
573
574    var dragThumbnail = this.renderThumbnail_();
575    dt.setDragImage(dragThumbnail, 0, 0);
576
577    window[DRAG_AND_DROP_GLOBAL_DATA] = {
578      sourceRootURL: dt.getData('fs/sourceRootURL'),
579      missingFileContents: dt.getData('fs/missingFileContents')
580    };
581  },
582
583  /**
584   * @this {FileTransferController}
585   * @param {cr.ui.List} list Drop target list.
586   * @param {Event} event A dragend event of DOM.
587   */
588  onDragEnd_: function(list, event) {
589    // TODO(fukino): This is workaround for crbug.com/373125.
590    // This should be removed after the bug is fixed.
591    this.touching_ = false;
592
593    var container = this.document_.querySelector('#drag-container');
594    container.textContent = '';
595    this.clearDropTarget_();
596    delete window[DRAG_AND_DROP_GLOBAL_DATA];
597  },
598
599  /**
600   * @this {FileTransferController}
601   * @param {boolean} onlyIntoDirectories True if the drag is only into
602   *     directories.
603   * @param {cr.ui.List} list Drop target list.
604   * @param {Event} event A dragover event of DOM.
605   */
606  onDragOver_: function(onlyIntoDirectories, list, event) {
607    event.preventDefault();
608    var entry = this.destinationEntry_ ||
609        (!onlyIntoDirectories && this.currentDirectoryContentEntry);
610    event.dataTransfer.dropEffect = this.selectDropEffect_(event, entry);
611    event.preventDefault();
612  },
613
614  /**
615   * @this {FileTransferController}
616   * @param {cr.ui.List} list Drop target list.
617   * @param {Event} event A dragenter event of DOM.
618   */
619  onDragEnterFileList_: function(list, event) {
620    event.preventDefault();  // Required to prevent the cursor flicker.
621    this.lastEnteredTarget_ = event.target;
622    var item = list.getListItemAncestor(event.target);
623    item = item && list.isItem(item) ? item : null;
624    if (item === this.dropTarget_)
625      return;
626
627    var entry = item && list.dataModel.item(item.listIndex);
628    if (entry)
629      this.setDropTarget_(item, event.dataTransfer, entry);
630    else
631      this.clearDropTarget_();
632  },
633
634  /**
635   * @this {FileTransferController}
636   * @param {DirectoryTree} tree Drop target tree.
637   * @param {Event} event A dragenter event of DOM.
638   */
639  onDragEnterTree_: function(tree, event) {
640    event.preventDefault();  // Required to prevent the cursor flicker.
641    this.lastEnteredTarget_ = event.target;
642    var item = event.target;
643    while (item && !(item instanceof cr.ui.TreeItem)) {
644      item = item.parentNode;
645    }
646
647    if (item === this.dropTarget_)
648      return;
649
650    var entry = item && item.entry;
651    if (entry) {
652      this.setDropTarget_(item, event.dataTransfer, entry);
653    } else {
654      this.clearDropTarget_();
655    }
656  },
657
658  /**
659   * @this {FileTransferController}
660   * @param {cr.ui.List} list Drop target list.
661   * @param {Event} event A dragleave event of DOM.
662   */
663  onDragLeave_: function(list, event) {
664    // If mouse moves from one element to another the 'dragenter'
665    // event for the new element comes before the 'dragleave' event for
666    // the old one. In this case event.target !== this.lastEnteredTarget_
667    // and handler of the 'dragenter' event has already caried of
668    // drop target. So event.target === this.lastEnteredTarget_
669    // could only be if mouse goes out of listened element.
670    if (event.target === this.lastEnteredTarget_) {
671      this.clearDropTarget_();
672      this.lastEnteredTarget_ = null;
673    }
674  },
675
676  /**
677   * @this {FileTransferController}
678   * @param {boolean} onlyIntoDirectories True if the drag is only into
679   *     directories.
680   * @param {Event} event A dragleave event of DOM.
681   */
682  onDrop_: function(onlyIntoDirectories, event) {
683    if (onlyIntoDirectories && !this.dropTarget_)
684      return;
685    var destinationEntry = this.destinationEntry_ ||
686                           this.currentDirectoryContentEntry;
687    if (!this.canPasteOrDrop_(event.dataTransfer, destinationEntry))
688      return;
689    event.preventDefault();
690    this.paste(event.dataTransfer, destinationEntry,
691               this.selectDropEffect_(event, destinationEntry));
692    this.clearDropTarget_();
693  },
694
695  /**
696   * Sets the drop target.
697   *
698   * @this {FileTransferController}
699   * @param {Element} domElement Target of the drop.
700   * @param {DataTransfer} dataTransfer Data transfer object.
701   * @param {DirectoryEntry} destinationEntry Destination entry.
702   */
703  setDropTarget_: function(domElement, dataTransfer, destinationEntry) {
704    if (this.dropTarget_ === domElement)
705      return;
706
707    // Remove the old drop target.
708    this.clearDropTarget_();
709
710    // Set the new drop target.
711    this.dropTarget_ = domElement;
712
713    if (!domElement ||
714        !destinationEntry.isDirectory ||
715        !this.canPasteOrDrop_(dataTransfer, destinationEntry)) {
716      return;
717    }
718
719    // Add accept class if the domElement can accept the drag.
720    domElement.classList.add('accepts');
721    this.destinationEntry_ = destinationEntry;
722
723    // Start timer changing the directory.
724    this.navigateTimer_ = setTimeout(function() {
725      if (domElement instanceof DirectoryItem)
726        // Do custom action.
727        (/** @type {DirectoryItem} */ domElement).doDropTargetAction();
728      this.directoryModel_.changeDirectoryEntry(destinationEntry);
729    }.bind(this), 2000);
730  },
731
732  /**
733   * Handles touch start.
734   */
735  onTouchStart_: function() {
736    this.touching_ = true;
737  },
738
739  /**
740   * Handles touch end.
741   */
742  onTouchEnd_: function(event) {
743    // TODO(fukino): We have to check if event.touches.length be 0 to support
744    // multi-touch operations, but event.touches has incorrect value by a bug
745    // (crbug.com/373125).
746    // After the bug is fixed, we should check event.touches.
747    this.touching_ = false;
748  },
749
750  /**
751   * Clears the drop target.
752   * @this {FileTransferController}
753   */
754  clearDropTarget_: function() {
755    if (this.dropTarget_ && this.dropTarget_.classList.contains('accepts'))
756      this.dropTarget_.classList.remove('accepts');
757    this.dropTarget_ = null;
758    this.destinationEntry_ = null;
759    if (this.navigateTimer_ !== undefined) {
760      clearTimeout(this.navigateTimer_);
761      this.navigateTimer_ = undefined;
762    }
763  },
764
765  /**
766   * @this {FileTransferController}
767   * @return {boolean} Returns false if {@code <input type="text">} element is
768   *     currently active. Otherwise, returns true.
769   */
770  isDocumentWideEvent_: function() {
771    return this.document_.activeElement.nodeName.toLowerCase() !== 'input' ||
772        this.document_.activeElement.type.toLowerCase() !== 'text';
773  },
774
775  /**
776   * @this {FileTransferController}
777   */
778  onCopy_: function(event) {
779    if (!this.isDocumentWideEvent_() ||
780        !this.canCopyOrDrag_()) {
781      return;
782    }
783    event.preventDefault();
784    this.cutOrCopy_(event.clipboardData, 'copy');
785    this.notify_('selection-copied');
786  },
787
788  /**
789   * @this {FileTransferController}
790   */
791  onBeforeCopy_: function(event) {
792    if (!this.isDocumentWideEvent_())
793      return;
794
795    // queryCommandEnabled returns true if event.defaultPrevented is true.
796    if (this.canCopyOrDrag_())
797      event.preventDefault();
798  },
799
800  /**
801   * @this {FileTransferController}
802   * @return {boolean} Returns true if all selected files are available to be
803   *     copied.
804   */
805  isAllSelectedFilesAvailable_: function() {
806    if (!this.currentDirectoryContentEntry)
807      return false;
808    var volumeInfo = this.volumeManager_.getVolumeInfo(
809        this.currentDirectoryContentEntry);
810    if (!volumeInfo)
811      return false;
812    var isDriveOffline = this.volumeManager_.getDriveConnectionState().type ===
813        VolumeManagerCommon.DriveConnectionType.OFFLINE;
814    if (this.isOnDrive && isDriveOffline && !this.allDriveFilesAvailable)
815      return false;
816    return true;
817  },
818
819  /**
820   * @this {FileTransferController}
821   * @return {boolean} Returns true if some files are selected and all the file
822   *     on drive is available to be copied. Otherwise, returns false.
823   */
824  canCopyOrDrag_: function() {
825    return this.isAllSelectedFilesAvailable_() &&
826        this.selectedEntries_.length > 0;
827  },
828
829  /**
830   * @this {FileTransferController}
831   */
832  onCut_: function(event) {
833    if (!this.isDocumentWideEvent_() ||
834        !this.canCutOrDrag_()) {
835      return;
836    }
837    event.preventDefault();
838    this.cutOrCopy_(event.clipboardData, 'move');
839    this.notify_('selection-cut');
840  },
841
842  /**
843   * @this {FileTransferController}
844   */
845  onBeforeCut_: function(event) {
846    if (!this.isDocumentWideEvent_())
847      return;
848    // queryCommandEnabled returns true if event.defaultPrevented is true.
849    if (this.canCutOrDrag_())
850      event.preventDefault();
851  },
852
853  /**
854   * @this {FileTransferController}
855   * @return {boolean} Returns true if the current directory is not read only.
856   */
857  canCutOrDrag_: function() {
858    return !this.readonly && this.selectedEntries_.length > 0;
859  },
860
861  /**
862   * @this {FileTransferController}
863   */
864  onPaste_: function(event) {
865    // If the event has destDirectory property, paste files into the directory.
866    // This occurs when the command fires from menu item 'Paste into folder'.
867    var destination = event.destDirectory || this.currentDirectoryContentEntry;
868
869    // Need to update here since 'beforepaste' doesn't fire.
870    if (!this.isDocumentWideEvent_() ||
871        !this.canPasteOrDrop_(event.clipboardData, destination)) {
872      return;
873    }
874    event.preventDefault();
875    var effect = this.paste(event.clipboardData, destination);
876
877    // On cut, we clear the clipboard after the file is pasted/moved so we don't
878    // try to move/delete the original file again.
879    if (effect === 'move') {
880      this.simulateCommand_('cut', function(event) {
881        event.preventDefault();
882        event.clipboardData.setData('fs/clear', '');
883      });
884    }
885  },
886
887  /**
888   * @this {FileTransferController}
889   */
890  onBeforePaste_: function(event) {
891    if (!this.isDocumentWideEvent_())
892      return;
893    // queryCommandEnabled returns true if event.defaultPrevented is true.
894    if (this.canPasteOrDrop_(event.clipboardData,
895                             this.currentDirectoryContentEntry)) {
896      event.preventDefault();
897    }
898  },
899
900  /**
901   * @this {FileTransferController}
902   * @param {DataTransfer} dataTransfer Data transfer object.
903   * @param {DirectoryEntry} destinationEntry Destination entry.
904   * @return {boolean} Returns true if items stored in {@code dataTransfer} can
905   *     be pasted to {@code destinationEntry}. Otherwise, returns false.
906   */
907  canPasteOrDrop_: function(dataTransfer, destinationEntry) {
908    if (!destinationEntry)
909      return false;
910    var destinationLocationInfo =
911        this.volumeManager_.getLocationInfo(destinationEntry);
912    if (!destinationLocationInfo || destinationLocationInfo.isReadOnly)
913      return false;
914    if (!dataTransfer.types || dataTransfer.types.indexOf('fs/tag') === -1)
915      return false;  // Unsupported type of content.
916
917    // Copying between different sources requires all files to be available.
918    if (this.getSourceRootURL_(dataTransfer) !==
919        destinationLocationInfo.volumeInfo.fileSystem.root.toURL() &&
920        this.isMissingFileContents_(dataTransfer))
921      return false;
922
923    return true;
924  },
925
926  /**
927   * Execute paste command.
928   *
929   * @this {FileTransferController}
930   * @return {boolean}  Returns true, the paste is success. Otherwise, returns
931   *     false.
932   */
933  queryPasteCommandEnabled: function() {
934    if (!this.isDocumentWideEvent_()) {
935      return false;
936    }
937
938    // HACK(serya): return this.document_.queryCommandEnabled('paste')
939    // should be used.
940    var result;
941    this.simulateCommand_('paste', function(event) {
942      result = this.canPasteOrDrop_(event.clipboardData,
943                                    this.currentDirectoryContentEntry);
944    }.bind(this));
945    return result;
946  },
947
948  /**
949   * Allows to simulate commands to get access to clipboard.
950   *
951   * @this {FileTransferController}
952   * @param {string} command 'copy', 'cut' or 'paste'.
953   * @param {function} handler Event handler.
954   */
955  simulateCommand_: function(command, handler) {
956    var iframe = this.document_.querySelector('#command-dispatcher');
957    var doc = iframe.contentDocument;
958    doc.addEventListener(command, handler);
959    doc.execCommand(command);
960    doc.removeEventListener(command, handler);
961  },
962
963  /**
964   * @this {FileTransferController}
965   */
966  onSelectionChanged_: function(event) {
967    var entries = this.selectedEntries_;
968    var files = this.selectedFileObjects_ = [];
969    this.preloadedThumbnailImagePromise_ = null;
970
971    var fileEntries = [];
972    for (var i = 0; i < entries.length; i++) {
973      if (entries[i].isFile)
974        fileEntries.push(entries[i]);
975    }
976    var containsDirectory = fileEntries.length !== entries.length;
977
978    // File object must be prepeared in advance for clipboard operations
979    // (copy, paste and drag). DataTransfer object closes for write after
980    // returning control from that handlers so they may not have
981    // asynchronous operations.
982    if (!containsDirectory) {
983      for (var i = 0; i < fileEntries.length; i++) {
984        fileEntries[i].file(function(file) { files.push(file); });
985      }
986    }
987
988    if (entries.length === 1) {
989      // For single selection, the dragged element is created in advance,
990      // otherwise an image may not be loaded at the time the 'dragstart' event
991      // comes.
992      this.preloadThumbnailImage_(entries[0]);
993    }
994
995    if (this.isOnDrive) {
996      this.allDriveFilesAvailable = false;
997      this.metadataCache_.get(entries, 'external', function(props) {
998        // We consider directories not available offline for the purposes of
999        // file transfer since we cannot afford to recursive traversal.
1000        this.allDriveFilesAvailable =
1001            !containsDirectory &&
1002            props.filter(function(p) {
1003              return !p.availableOffline;
1004            }).length === 0;
1005        // |Copy| is the only menu item affected by allDriveFilesAvailable.
1006        // It could be open right now, update its UI.
1007        this.copyCommand_.disabled = !this.canCopyOrDrag_();
1008      }.bind(this));
1009    }
1010  },
1011
1012  /**
1013   * Obains directory that is displaying now.
1014   * @this {FileTransferController}
1015   * @return {DirectoryEntry} Entry of directry that is displaying now.
1016   */
1017  get currentDirectoryContentEntry() {
1018    return this.directoryModel_.getCurrentDirEntry();
1019  },
1020
1021  /**
1022   * @this {FileTransferController}
1023   * @return {boolean} True if the current directory is read only.
1024   */
1025  get readonly() {
1026    return this.directoryModel_.isReadOnly();
1027  },
1028
1029  /**
1030   * @this {FileTransferController}
1031   * @return {boolean} True if the current directory is on Drive.
1032   */
1033  get isOnDrive() {
1034    var currentDir = this.directoryModel_.getCurrentDirEntry();
1035    if (!currentDir)
1036      return false;
1037    var locationInfo = this.volumeManager_.getLocationInfo(currentDir);
1038    if (!locationInfo)
1039      return false;
1040    return locationInfo.isDriveBased;
1041  },
1042
1043  /**
1044   * @this {FileTransferController}
1045   */
1046  notify_: function(eventName) {
1047    var self = this;
1048    // Set timeout to avoid recursive events.
1049    setTimeout(function() {
1050      cr.dispatchSimpleEvent(self, eventName);
1051    }, 0);
1052  },
1053
1054  /**
1055   * @this {FileTransferController}
1056   * @return {Array.<Entry>} Array of the selected entries.
1057   */
1058  get selectedEntries_() {
1059    var list = this.directoryModel_.getFileList();
1060    var selectedIndexes = this.directoryModel_.getFileListSelection().
1061        selectedIndexes;
1062    var entries = selectedIndexes.map(function(index) {
1063      return list.item(index);
1064    });
1065
1066    // TODO(serya): Diagnostics for http://crbug/129642
1067    if (entries.indexOf(undefined) !== -1) {
1068      var index = entries.indexOf(undefined);
1069      entries = entries.filter(function(e) { return !!e; });
1070      console.error('Invalid selection found: list items: ', list.length,
1071                    'wrong indexe value: ', selectedIndexes[index],
1072                    'Stack trace: ', new Error().stack);
1073    }
1074    return entries;
1075  },
1076
1077  /**
1078   * @param {Event} event Drag event.
1079   * @param {DirectoryEntry} destinationEntry Destination entry.
1080   * @this {FileTransferController}
1081   * @return {string}  Returns the appropriate drop query type ('none', 'move'
1082   *     or copy') to the current modifiers status and the destination.
1083   */
1084  selectDropEffect_: function(event, destinationEntry) {
1085    if (!destinationEntry)
1086      return 'none';
1087    var destinationLocationInfo =
1088        this.volumeManager_.getLocationInfo(destinationEntry);
1089    if (!destinationLocationInfo)
1090      return 'none';
1091    if (destinationLocationInfo.isReadOnly)
1092      return 'none';
1093    if (util.isDropEffectAllowed(event.dataTransfer.effectAllowed, 'move')) {
1094      if (!util.isDropEffectAllowed(event.dataTransfer.effectAllowed, 'copy'))
1095        return 'move';
1096      // TODO(mtomasz): Use volumeId instead of comparing roots, as soon as
1097      // volumeId gets unique.
1098      if (this.getSourceRootURL_(event.dataTransfer) ===
1099              destinationLocationInfo.volumeInfo.fileSystem.root.toURL() &&
1100          !event.ctrlKey) {
1101        return 'move';
1102      }
1103      if (event.shiftKey) {
1104        return 'move';
1105      }
1106    }
1107    return 'copy';
1108  },
1109};
1110