// Copyright (c) 2011 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. // WK Bug 55728 is fixed on the chrome 12 branch but not on the trunk. // TODO(rginda): Enable this everywhere once we have a trunk-worthy fix. const ENABLE_EXIF_READER = navigator.userAgent.match(/chrome\/12\.0/i); // Thumbnail view is painful without the exif reader. const ENABLE_THUMBNAIL_VIEW = ENABLE_EXIF_READER; var g_slideshow_data = null; /** * FileManager constructor. * * FileManager objects encapsulate the functionality of the file selector * dialogs, as well as the full screen file manager application (though the * latter is not yet implemented). * * @param {HTMLElement} dialogDom The DOM node containing the prototypical * dialog UI. * @param {DOMFileSystem} filesystem The HTML5 filesystem object representing * the root filesystem for the new FileManager. * @param {Object} params A map of parameter names to values controlling the * appearance of the FileManager. Names are: * - type: A value from FileManager.DialogType defining what kind of * dialog to present. Defaults to FULL_PAGE. * - title: The title for the dialog. Defaults to a localized string based * on the dialog type. * - defaultPath: The default path for the dialog. The default path should * end with a trailing slash if it represents a directory. */ function FileManager(dialogDom, rootEntries, params) { console.log('Init FileManager: ' + dialogDom); this.dialogDom_ = dialogDom; this.rootEntries_ = rootEntries; this.filesystem_ = rootEntries[0].filesystem; this.params_ = params || {}; this.listType_ = null; this.exifCache_ = {}; // True if we should filter out files that start with a dot. this.filterFiles_ = true; this.commands_ = {}; this.document_ = dialogDom.ownerDocument; this.dialogType_ = this.params_.type || FileManager.DialogType.FULL_PAGE; this.defaultPath_ = this.params_.defaultPath || '/'; // This is set to just the directory portion of defaultPath in initDialogType. this.defaultFolder_ = '/'; this.showCheckboxes_ = (this.dialogType_ == FileManager.DialogType.FULL_PAGE || this.dialogType_ == FileManager.DialogType.SELECT_OPEN_MULTI_FILE); // DirectoryEntry representing the current directory of the dialog. this.currentDirEntry_ = null; window.addEventListener('popstate', this.onPopState_.bind(this)); this.addEventListener('directory-changed', this.onDirectoryChanged_.bind(this)); this.addEventListener('selection-summarized', this.onSelectionSummarized_.bind(this)); this.initCommands_(); this.initDom_(); this.initDialogType_(); this.summarizeSelection_(); this.updatePreview_(); this.changeDirectory(this.defaultFolder_); chrome.fileBrowserPrivate.onDiskChanged.addListener( this.onDiskChanged_.bind(this)); this.table_.list_.focus(); if (ENABLE_EXIF_READER) { this.exifReader = new Worker('js/exif_reader.js'); this.exifReader.onmessage = this.onExifReaderMessage_.bind(this); this.exifReader.postMessage({verb: 'init'}); } } FileManager.prototype = { __proto__: cr.EventTarget.prototype }; // Anonymous "namespace". (function() { // Private variables and helper functions. /** * Unicode codepoint for 'BLACK RIGHT-POINTING SMALL TRIANGLE'. */ const RIGHT_TRIANGLE = '\u25b8'; /** * The DirectoryEntry.fullPath value of the directory containing external * storage volumes. */ const MEDIA_DIRECTORY = '/media'; /** * Translated strings. */ var localStrings; /** * Map of icon types to regular expressions. * * The first regexp to match the file name determines the icon type * assigned to dom elements for a file. Order of evaluation is not * defined, so don't depend on it. */ const iconTypes = { 'audio': /\.(mp3|m4a|oga|ogg|wav)$/i, 'html': /\.(html?)$/i, 'image': /\.(bmp|gif|jpe?g|ico|png|webp)$/i, 'pdf' : /\.(pdf)$/i, 'text': /\.(pod|rst|txt|log)$/i, 'video': /\.(mov|mp4|m4v|mpe?g4?|ogm|ogv|ogx|webm)$/i }; const previewArt = { 'audio': 'images/filetype_large_audio.png', 'folder': 'images/filetype_large_folder.png', 'unknown': 'images/filetype_large_generic.png', 'video': 'images/filetype_large_video.png' }; /** * Return a translated string. * * Wrapper function to make dealing with translated strings more concise. * Equivilant to localStrings.getString(id). * * @param {string} id The id of the string to return. * @return {string} The translated string. */ function str(id) { return localStrings.getString(id); } /** * Return a translated string with arguments replaced. * * Wrapper function to make dealing with translated strings more concise. * Equivilant to localStrings.getStringF(id, ...). * * @param {string} id The id of the string to return. * @param {...string} The values to replace into the string. * @return {string} The translated string with replaced values. */ function strf(id, var_args) { return localStrings.getStringF.apply(localStrings, arguments); } /** * Checks if |parent_path| is parent file path of |child_path|. * * @param {string} parent_path The parent path. * @param {string} child_path The child path. */ function isParentPath(parent_path, child_path) { if (!parent_path || parent_path.length == 0 || !child_path || child_path.length == 0) return false; if (parent_path[parent_path.length -1] != '/') parent_path += '/'; if (child_path[child_path.length -1] != '/') child_path += '/'; return child_path.indexOf(parent_path) == 0; } /** * Returns parent folder path of file path. * * @param {string} path The file path. */ function getParentPath(path) { var parent = path.replace(/[\/]?[^\/]+[\/]?$/,''); if (parent.length == 0) parent = '/'; return parent; } /** * Get the icon type for a given Entry. * * @param {Entry} entry An Entry subclass (FileEntry or DirectoryEntry). * @return {string} One of the keys from FileManager.iconTypes, or * 'unknown'. */ function getIconType(entry) { if (entry.cachedIconType_) return entry.cachedIconType_; var rv = 'unknown'; if (entry.isDirectory) { rv = 'folder'; } else { for (var name in iconTypes) { var value = iconTypes[name]; if (value instanceof RegExp) { if (value.test(entry.name)) { rv = name; break; } } else if (typeof value == 'function') { try { if (value(entry)) { rv = name; break; } } catch (ex) { console.error('Caught exception while evaluating iconType: ' + name, ex); } } else { console.log('Unexpected value in iconTypes[' + name + ']: ' + value); } } } entry.cachedIconType_ = rv; return rv; } /** * Call an asynchronous method on dirEntry, batching multiple callers. * * This batches multiple callers into a single invocation, calling all * interested parties back when the async call completes. * * The Entry method to be invoked should take two callbacks as parameters * (one for success and one for failure), and it should invoke those * callbacks with a single parameter representing the result of the call. * Example methods are Entry.getMetadata() and FileEntry.file(). * * Warning: Because this method caches the first result, subsequent changes * to the entry will not be visible to callers. * * Error results are never cached. * * @param {DirectoryEntry} dirEntry The DirectoryEntry to apply the method * to. * @param {string} methodName The name of the method to dispatch. * @param {function(*)} successCallback The function to invoke if the method * succeeds. The result of the method will be the one parameter to this * callback. * @param {function(*)} opt_errorCallback The function to invoke if the * method fails. The result of the method will be the one parameter to * this callback. If not provided, the default errorCallback will throw * an exception. */ function batchAsyncCall(entry, methodName, successCallback, opt_errorCallback) { var resultCache = methodName + '_resultCache_'; if (entry[resultCache]) { // The result cache for this method already exists. Just invoke the // successCallback with the result of the previuos call. // Callback via a setTimeout so the sync/async semantics don't change // based on whether or not the value is cached. setTimeout(function() { successCallback(entry[resultCache]) }, 0); return; } if (!opt_errorCallback) { opt_errorCallback = util.ferr('Error calling ' + methodName + ' for: ' + entry.fullPath); } var observerList = methodName + '_observers_'; if (entry[observerList]) { // The observer list already exists, indicating we have a pending call // out to this method. Add this caller to the list of observers and // bail out. entry[observerList].push([successCallback, opt_errorCallback]); return; } entry[observerList] = [[successCallback, opt_errorCallback]]; function onComplete(success, result) { if (success) entry[resultCache] = result; for (var i = 0; i < entry[observerList].length; i++) { entry[observerList][i][success ? 0 : 1](result); } delete entry[observerList]; }; entry[methodName](function(rv) { onComplete(true, rv) }, function(rv) { onComplete(false, rv) }); } /** * Get the size of a file, caching the result. * * When this method completes, the fileEntry object will get a * 'cachedSize_' property (if it doesn't already have one) containing the * size of the file in bytes. * * @param {Entry} entry An HTML5 Entry object. * @param {function(Entry)} successCallback The function to invoke once the * file size is known. */ function cacheEntrySize(entry, successCallback) { if (entry.isDirectory) { // No size for a directory, -1 ensures it's sorted before 0 length files. entry.cachedSize_ = -1; } if ('cachedSize_' in entry) { if (successCallback) { // Callback via a setTimeout so the sync/async semantics don't change // based on whether or not the value is cached. setTimeout(function() { successCallback(entry) }, 0); } return; } batchAsyncCall(entry, 'file', function(file) { entry.cachedSize_ = file.size; if (successCallback) successCallback(entry); }); } /** * Get the mtime of a file, caching the result. * * When this method completes, the fileEntry object will get a * 'cachedMtime_' property (if it doesn't already have one) containing the * last modified time of the file as a Date object. * * @param {Entry} entry An HTML5 Entry object. * @param {function(Entry)} successCallback The function to invoke once the * mtime is known. */ function cacheEntryDate(entry, successCallback) { if ('cachedMtime_' in entry) { if (successCallback) { // Callback via a setTimeout so the sync/async semantics don't change // based on whether or not the value is cached. setTimeout(function() { successCallback(entry) }, 0); } return; } if (entry.isFile) { batchAsyncCall(entry, 'file', function(file) { entry.cachedMtime_ = file.lastModifiedDate; if (successCallback) successCallback(entry); }); } else { batchAsyncCall(entry, 'getMetadata', function(metadata) { entry.cachedMtime_ = metadata.modificationTime; if (successCallback) successCallback(entry); }); } } /** * Get the icon type of a file, caching the result. * * When this method completes, the fileEntry object will get a * 'cachedIconType_' property (if it doesn't already have one) containing the * icon type of the file as a string. * * The successCallback is always invoked synchronously, since this does not * actually require an async call. You should not depend on this, as it may * change if we were to start reading magic numbers (for example). * * @param {Entry} entry An HTML5 Entry object. * @param {function(Entry)} successCallback The function to invoke once the * icon type is known. */ function cacheEntryIconType(entry, successCallback) { getIconType(entry); if (successCallback) setTimeout(function() { successCallback(entry) }, 0); } // Public statics. /** * List of dialog types. * * Keep this in sync with FileManagerDialog::GetDialogTypeAsString, except * FULL_PAGE which is specific to this code. * * @enum {string} */ FileManager.DialogType = { SELECT_FOLDER: 'folder', SELECT_SAVEAS_FILE: 'saveas-file', SELECT_OPEN_FILE: 'open-file', SELECT_OPEN_MULTI_FILE: 'open-multi-file', FULL_PAGE: 'full-page' }; FileManager.ListType = { DETAIL: 'detail', THUMBNAIL: 'thumb' }; /** * Load translated strings. */ FileManager.initStrings = function(callback) { chrome.fileBrowserPrivate.getStrings(function(strings) { localStrings = new LocalStrings(strings); cr.initLocale(strings); if (callback) callback(); }); }; // Instance methods. /** * One-time initialization of commands. */ FileManager.prototype.initCommands_ = function() { var commands = this.dialogDom_.querySelectorAll('command'); for (var i = 0; i < commands.length; i++) { var command = commands[i]; cr.ui.Command.decorate(command); this.commands_[command.id] = command; } this.fileContextMenu_ = this.dialogDom_.querySelector('.file-context-menu'); cr.ui.Menu.decorate(this.fileContextMenu_); this.document_.addEventListener( 'canExecute', this.onRenameCanExecute_.bind(this)); this.document_.addEventListener( 'canExecute', this.onDeleteCanExecute_.bind(this)); this.document_.addEventListener('command', this.onCommand_.bind(this)); } /** * One-time initialization of various DOM nodes. */ FileManager.prototype.initDom_ = function() { // Cache nodes we'll be manipulating. this.previewImage_ = this.dialogDom_.querySelector('.preview-img'); this.previewFilename_ = this.dialogDom_.querySelector('.preview-filename'); this.previewSummary_ = this.dialogDom_.querySelector('.preview-summary'); this.filenameInput_ = this.dialogDom_.querySelector('.filename-input'); this.taskButtons_ = this.dialogDom_.querySelector('.task-buttons'); this.okButton_ = this.dialogDom_.querySelector('.ok'); this.cancelButton_ = this.dialogDom_.querySelector('.cancel'); this.newFolderButton_ = this.dialogDom_.querySelector('.new-folder'); this.renameInput_ = this.document_.createElement('input'); this.renameInput_.className = 'rename'; this.renameInput_.addEventListener( 'keydown', this.onRenameInputKeyDown_.bind(this)); this.renameInput_.addEventListener( 'blur', this.onRenameInputBlur_.bind(this)); this.filenameInput_.addEventListener( 'keyup', this.onFilenameInputKeyUp_.bind(this)); this.filenameInput_.addEventListener( 'focus', this.onFilenameInputFocus_.bind(this)); this.dialogDom_.addEventListener('keydown', this.onKeyDown_.bind(this)); this.okButton_.addEventListener('click', this.onOk_.bind(this)); this.cancelButton_.addEventListener('click', this.onCancel_.bind(this)); this.dialogDom_.querySelector('button.new-folder').addEventListener( 'click', this.onNewFolderButtonClick_.bind(this)); if (ENABLE_THUMBNAIL_VIEW) { this.dialogDom_.querySelector('button.detail-view').addEventListener( 'click', this.onDetailViewButtonClick_.bind(this)); this.dialogDom_.querySelector('button.thumbnail-view').addEventListener( 'click', this.onThumbnailViewButtonClick_.bind(this)); } else { this.dialogDom_.querySelector( 'button.detail-view').style.display = 'none'; this.dialogDom_.querySelector( 'button.thumbnail-view').style.display = 'none'; } this.dialogDom_.ownerDocument.defaultView.addEventListener( 'resize', this.onResize_.bind(this)); var ary = this.dialogDom_.querySelectorAll('[visibleif]'); for (var i = 0; i < ary.length; i++) { var expr = ary[i].getAttribute('visibleif'); if (!eval(expr)) ary[i].style.display = 'none'; } // Populate the static localized strings. i18nTemplate.process(this.document_, localStrings.templateData); // Always sharing the data model between the detail/thumb views confuses // them. Instead we maintain this bogus data model, and hook it up to the // view that is not in use. this.emptyDataModel_ = new cr.ui.table.TableDataModel([]); this.dataModel_ = new cr.ui.table.TableDataModel([]); this.dataModel_.sort('name'); this.dataModel_.addEventListener('sorted', this.onDataModelSorted_.bind(this)); this.dataModel_.prepareSort = this.prepareSort_.bind(this); if (this.dialogType_ == FileManager.DialogType.SELECT_OPEN_FILE || this.dialogType_ == FileManager.DialogType.SELECT_OPEN_FOLDER || this.dialogType_ == FileManager.DialogType.SELECT_SAVEAS_FILE) { this.selectionModelClass_ = cr.ui.table.TableSingleSelectionModel; } else { this.selectionModelClass_ = cr.ui.table.TableSelectionModel; } this.initTable_(); this.initGrid_(); this.setListType(FileManager.ListType.DETAIL); this.onResize_(); this.dialogDom_.style.opacity = '1'; }; /** * Force the canExecute events to be dispatched. */ FileManager.prototype.updateCommands_ = function() { this.commands_['rename'].canExecuteChange(); this.commands_['delete'].canExecuteChange(); }; /** * Invoked to decide whether the "rename" command can be executed. */ FileManager.prototype.onRenameCanExecute_ = function(event) { event.canExecute = (// Full page mode. this.dialogType_ == FileManager.DialogType.FULL_PAGE && // Rename not in progress. !this.renameInput_.currentEntry && // Not in root directory. this.currentDirEntry_.fullPath != '/' && // Not in media directory. this.currentDirEntry_.fullPath != MEDIA_DIRECTORY && // Only one file selected. this.selection.totalCount == 1); }; /** * Invoked to decide whether the "delete" command can be executed. */ FileManager.prototype.onDeleteCanExecute_ = function(event) { event.canExecute = (// Full page mode. this.dialogType_ == FileManager.DialogType.FULL_PAGE && // Rename not in progress. !this.renameInput_.currentEntry && // Not in root directory. this.currentDirEntry_.fullPath != '/' && // Not in media directory. this.currentDirEntry_.fullPath != MEDIA_DIRECTORY); }; FileManager.prototype.setListType = function(type) { if (type && type == this.listType_) return; if (type == FileManager.ListType.DETAIL) { this.table_.dataModel = this.dataModel_; this.table_.style.display = ''; this.grid_.style.display = 'none'; this.grid_.dataModel = this.emptyDataModel_; this.currentList_ = this.table_; this.dialogDom_.querySelector('button.detail-view').disabled = true; this.dialogDom_.querySelector('button.thumbnail-view').disabled = false; } else if (type == FileManager.ListType.THUMBNAIL) { this.grid_.dataModel = this.dataModel_; this.grid_.style.display = ''; this.table_.style.display = 'none'; this.table_.dataModel = this.emptyDataModel_; this.currentList_ = this.grid_; this.dialogDom_.querySelector('button.thumbnail-view').disabled = true; this.dialogDom_.querySelector('button.detail-view').disabled = false; } else { throw new Error('Unknown list type: ' + type); } this.listType_ = type; this.onResize_(); }; /** * Initialize the file thumbnail grid. */ FileManager.prototype.initGrid_ = function() { this.grid_ = this.dialogDom_.querySelector('.thumbnail-grid'); cr.ui.Grid.decorate(this.grid_); var self = this; this.grid_.itemConstructor = function(entry) { return self.renderThumbnail_(entry); }; this.grid_.selectionModel = new this.selectionModelClass_(); this.grid_.addEventListener( 'dblclick', this.onDetailDoubleClick_.bind(this)); this.grid_.selectionModel.addEventListener( 'change', this.onSelectionChanged_.bind(this)); if (this.dialogType_ == FileManager.DialogType.FULL_PAGE) { cr.ui.contextMenuHandler.addContextMenuProperty(this.grid_); this.grid_.contextMenu = this.fileContextMenu_; } this.grid_.addEventListener('mousedown', this.onGridMouseDown_.bind(this)); }; /** * Initialize the file list table. */ FileManager.prototype.initTable_ = function() { var checkWidth = this.showCheckboxes_ ? 5 : 0; var columns = [ new cr.ui.table.TableColumn('cachedIconType_', '', 5.4 + checkWidth), new cr.ui.table.TableColumn('name', str('NAME_COLUMN_LABEL'), 64 - checkWidth), new cr.ui.table.TableColumn('cachedSize_', str('SIZE_COLUMN_LABEL'), 15.5), new cr.ui.table.TableColumn('cachedMtime_', str('DATE_COLUMN_LABEL'), 21) ]; columns[0].renderFunction = this.renderIconType_.bind(this); columns[1].renderFunction = this.renderName_.bind(this); columns[2].renderFunction = this.renderSize_.bind(this); columns[3].renderFunction = this.renderDate_.bind(this); this.table_ = this.dialogDom_.querySelector('.detail-table'); cr.ui.Table.decorate(this.table_); this.table_.selectionModel = new this.selectionModelClass_(); this.table_.columnModel = new cr.ui.table.TableColumnModel(columns); this.table_.addEventListener( 'dblclick', this.onDetailDoubleClick_.bind(this)); this.table_.selectionModel.addEventListener( 'change', this.onSelectionChanged_.bind(this)); if (this.dialogType_ == FileManager.DialogType.FULL_PAGE) { cr.ui.contextMenuHandler.addContextMenuProperty(this.table_); this.table_.contextMenu = this.fileContextMenu_; } this.table_.addEventListener('mousedown', this.onTableMouseDown_.bind(this)); }; /** * Respond to a command being executed. */ FileManager.prototype.onCommand_ = function(event) { switch (event.command.id) { case 'rename': var leadIndex = this.currentList_.selectionModel.leadIndex; var li = this.currentList_.getListItemByIndex(leadIndex); var label = li.querySelector('.filename-label'); if (!label) { console.warn('Unable to find label for rename of index: ' + leadIndex); return; } this.initiateRename_(label); break; case 'delete': this.deleteEntries(this.selection.entries); break; } }; /** * Respond to the back button. */ FileManager.prototype.onPopState_ = function(event) { this.changeDirectory(event.state, false); }; /** * Resize details and thumb views to fit the new window size. */ FileManager.prototype.onResize_ = function() { this.table_.style.height = this.grid_.style.height = this.grid_.parentNode.clientHeight + 'px'; this.table_.style.width = this.grid_.style.width = this.grid_.parentNode.clientWidth + 'px'; this.table_.list_.style.width = this.table_.parentNode.clientWidth + 'px'; this.table_.list_.style.height = (this.table_.clientHeight - 1 - this.table_.header_.clientHeight) + 'px'; if (this.listType_ == FileManager.ListType.THUMBNAIL) { var self = this; setTimeout(function () { self.grid_.columns = 0; self.grid_.redraw(); }, 0); } else { this.currentList_.redraw(); } }; /** * Tweak the UI to become a particular kind of dialog, as determined by the * dialog type parameter passed to the constructor. */ FileManager.prototype.initDialogType_ = function() { var defaultTitle; var okLabel = str('OPEN_LABEL'); // Split the dirname from the basename. var ary = this.defaultPath_.match(/^(.*?)(?:\/([^\/]+))?$/); var defaultFolder; var defaultTarget; if (!ary) { console.warn('Unable to split defaultPath: ' + defaultPath); ary = []; } switch (this.dialogType_) { case FileManager.DialogType.SELECT_FOLDER: defaultTitle = str('SELECT_FOLDER_TITLE'); defaultFolder = ary[1] || '/'; defaultTarget = ary[2] || ''; break; case FileManager.DialogType.SELECT_OPEN_FILE: defaultTitle = str('SELECT_OPEN_FILE_TITLE'); defaultFolder = ary[1] || '/'; defaultTarget = ''; if (ary[2]) { console.warn('Open should NOT have provided a default ' + 'filename: ' + ary[2]); } break; case FileManager.DialogType.SELECT_OPEN_MULTI_FILE: defaultTitle = str('SELECT_OPEN_MULTI_FILE_TITLE'); defaultFolder = ary[1] || '/'; defaultTarget = ''; if (ary[2]) { console.warn('Multi-open should NOT have provided a default ' + 'filename: ' + ary[2]); } break; case FileManager.DialogType.SELECT_SAVEAS_FILE: defaultTitle = str('SELECT_SAVEAS_FILE_TITLE'); okLabel = str('SAVE_LABEL'); defaultFolder = ary[1] || '/'; defaultTarget = ary[2] || ''; if (!defaultTarget) console.warn('Save-as should have provided a default filename.'); break; case FileManager.DialogType.FULL_PAGE: defaultFolder = ary[1] || '/'; defaultTarget = ary[2] || ''; break; default: throw new Error('Unknown dialog type: ' + this.dialogType_); } this.okButton_.textContent = okLabel; dialogTitle = this.params_.title || defaultTitle; this.dialogDom_.querySelector('.dialog-title').textContent = dialogTitle; ary = defaultFolder.match(/^\/home\/[^\/]+\/user\/Downloads(\/.*)?$/); if (ary) { // Chrome will probably suggest the full path to Downloads, but // we're working with 'virtual paths', so we have to translate. // TODO(rginda): Maybe chrome should have suggested the correct place // to begin with, but that would mean it would have to treat the // file manager dialogs differently than the native ones. defaultFolder = '/Downloads' + (ary[1] || ''); } this.defaultFolder_ = defaultFolder; this.filenameInput_.value = defaultTarget; }; /** * Cache necessary data before a sort happens. * * This is called by the table code before a sort happens, so that we can * go fetch data for the sort field that we may not have yet. */ FileManager.prototype.prepareSort_ = function(field, callback) { var cacheFunction; if (field == 'cachedMtime_') { cacheFunction = cacheEntryDate; } else if (field == 'cachedSize_') { cacheFunction = cacheEntrySize; } else if (field == 'cachedIconType_') { cacheFunction = cacheEntryIconType; } else { callback(); return; } function checkCount() { if (uncachedCount == 0) { // Callback via a setTimeout so the sync/async semantics don't change // based on whether or not the value is cached. setTimeout(callback, 0); } } var dataModel = this.dataModel_; var uncachedCount = dataModel.length; for (var i = uncachedCount - 1; i >= 0 ; i--) { var entry = dataModel.item(i); if (field in entry) { uncachedCount--; } else { cacheFunction(entry, function() { uncachedCount--; checkCount(); }); } } checkCount(); } /** * Render (and wire up) a checkbox to be used in either a detail or a * thumbnail list item. */ FileManager.prototype.renderCheckbox_ = function(entry) { var input = this.document_.createElement('input'); input.setAttribute('type', 'checkbox'); input.className = 'file-checkbox'; input.addEventListener('mousedown', this.onCheckboxMouseDownUp_.bind(this)); input.addEventListener('mouseup', this.onCheckboxMouseDownUp_.bind(this)); input.addEventListener('click', this.onCheckboxClick_.bind(this)); if (this.selection && this.selection.entries.indexOf(entry) != -1) { // Our DOM nodes get discarded as soon as we're scrolled out of view, // so we have to make sure the check state is correct when we're brought // back to life. input.checked = true; } return input; } FileManager.prototype.renderThumbnail_ = function(entry) { var li = this.document_.createElement('li'); li.className = 'thumbnail-item'; if (this.showCheckboxes_) li.appendChild(this.renderCheckbox_(entry)); var div = this.document_.createElement('div'); div.className = 'img-container'; li.appendChild(div); var img = this.document_.createElement('img'); this.getThumbnailURL(entry, function(type, url) { img.src = url }); div.appendChild(img); div = this.document_.createElement('div'); div.className = 'filename-label'; var labelText = entry.name; if (this.currentDirEntry_.name == '') labelText = this.getLabelForRootPath_(labelText); div.textContent = labelText; div.entry = entry; li.appendChild(div); cr.defineProperty(li, 'lead', cr.PropertyKind.BOOL_ATTR); cr.defineProperty(li, 'selected', cr.PropertyKind.BOOL_ATTR); return li; } /** * Render the type column of the detail table. * * Invoked by cr.ui.Table when a file needs to be rendered. * * @param {Entry} entry The Entry object to render. * @param {string} columnId The id of the column to be rendered. * @param {cr.ui.Table} table The table doing the rendering. */ FileManager.prototype.renderIconType_ = function(entry, columnId, table) { var div = this.document_.createElement('div'); div.className = 'detail-icon-container'; if (this.showCheckboxes_) div.appendChild(this.renderCheckbox_(entry)); var icon = this.document_.createElement('div'); icon.className = 'detail-icon'; entry.cachedIconType_ = getIconType(entry); icon.setAttribute('iconType', entry.cachedIconType_); div.appendChild(icon); return div; }; FileManager.prototype.getLabelForRootPath_ = function(path) { // This hack lets us localize the top level directories. if (path == 'Downloads') return str('DOWNLOADS_DIRECTORY_LABEL'); if (path == 'media') return str('MEDIA_DIRECTORY_LABEL'); return path || str('ROOT_DIRECTORY_LABEL'); }; /** * Render the Name column of the detail table. * * Invoked by cr.ui.Table when a file needs to be rendered. * * @param {Entry} entry The Entry object to render. * @param {string} columnId The id of the column to be rendered. * @param {cr.ui.Table} table The table doing the rendering. */ FileManager.prototype.renderName_ = function(entry, columnId, table) { var label = this.document_.createElement('div'); label.entry = entry; label.className = 'filename-label'; if (this.currentDirEntry_.name == '') { label.textContent = this.getLabelForRootPath_(entry.name); } else { label.textContent = entry.name; } return label; }; /** * Render the Size column of the detail table. * * @param {Entry} entry The Entry object to render. * @param {string} columnId The id of the column to be rendered. * @param {cr.ui.Table} table The table doing the rendering. */ FileManager.prototype.renderSize_ = function(entry, columnId, table) { var div = this.document_.createElement('div'); div.className = 'detail-size'; div.textContent = '...'; cacheEntrySize(entry, function(entry) { if (entry.cachedSize_ == -1) { div.textContent = ''; } else { div.textContent = cr.locale.bytesToSi(entry.cachedSize_); } }); return div; }; /** * Render the Date column of the detail table. * * @param {Entry} entry The Entry object to render. * @param {string} columnId The id of the column to be rendered. * @param {cr.ui.Table} table The table doing the rendering. */ FileManager.prototype.renderDate_ = function(entry, columnId, table) { var div = this.document_.createElement('div'); div.className = 'detail-date'; div.textContent = '...'; var self = this; cacheEntryDate(entry, function(entry) { if (self.currentDirEntry_.fullPath == MEDIA_DIRECTORY && entry.cachedMtime_.getTime() == 0) { // Mount points for FAT volumes have this time associated with them. // We'd rather display nothing than this bogus date. div.textContent = '---'; } else { div.textContent = cr.locale.formatDate(entry.cachedMtime_, str('LOCALE_FMT_DATE_SHORT')); } }); return div; }; /** * Compute summary information about the current selection. * * This method dispatches the 'selection-summarized' event when it completes. * Depending on how many of the selected files already have known sizes, the * dispatch may happen immediately, or after a number of async calls complete. */ FileManager.prototype.summarizeSelection_ = function() { var selection = this.selection = { entries: [], urls: [], leadEntry: null, totalCount: 0, fileCount: 0, directoryCount: 0, bytes: 0, iconType: null, indexes: this.currentList_.selectionModel.selectedIndexes }; this.previewSummary_.textContent = str('COMPUTING_SELECTION'); this.taskButtons_.innerHTML = ''; if (!selection.indexes.length) { cr.dispatchSimpleEvent(this, 'selection-summarized'); return; } var fileCount = 0; var byteCount = 0; var pendingFiles = []; for (var i = 0; i < selection.indexes.length; i++) { var entry = this.dataModel_.item(selection.indexes[i]); selection.entries.push(entry); selection.urls.push(entry.toURL()); if (selection.iconType == null) { selection.iconType = getIconType(entry); } else if (selection.iconType != 'unknown') { var iconType = getIconType(entry); if (selection.iconType != iconType) selection.iconType = 'unknown'; } selection.totalCount++; if (entry.isFile) { if (!('cachedSize_' in entry)) { // Any file that hasn't been rendered may be missing its cachedSize_ // property. For example, visit a large file list, and press ctrl-a // to select all. In this case, we need to asynchronously get the // sizes for these files before telling the world the selection has // been summarized. See the 'computeNextFile' logic below. pendingFiles.push(entry); continue; } else { selection.bytes += entry.cachedSize_; } selection.fileCount += 1; } else { selection.directoryCount += 1; } } var leadIndex = this.currentList_.selectionModel.leadIndex; if (leadIndex > -1) { selection.leadEntry = this.dataModel_.item(leadIndex); } else { selection.leadEntry = selection.entries[0]; } var self = this; function cacheNextFile(fileEntry) { if (fileEntry) { // We're careful to modify the 'selection', rather than 'self.selection' // here, just in case the selection has changed since this summarization // began. selection.bytes += fileEntry.cachedSize_; } if (pendingFiles.length) { cacheEntrySize(pendingFiles.pop(), cacheNextFile); } else { self.dispatchEvent(new cr.Event('selection-summarized')); } }; if (this.dialogType_ == FileManager.DialogType.FULL_PAGE) { chrome.fileBrowserPrivate.getFileTasks(selection.urls, this.onTasksFound_.bind(this)); } cacheNextFile(); }; FileManager.prototype.onExifGiven_ = function(fileURL, metadata) { var observers = this.exifCache_[fileURL]; if (!observers || !(observers instanceof Array)) { console.error('Missing or invalid exif observers: ' + fileURL + ': ' + observers); return; } for (var i = 0; i < observers.length; i++) { observers[i](metadata); } this.exifCache_[fileURL] = metadata; }; FileManager.prototype.onExifError_ = function(fileURL, step, error) { console.warn('Exif error: ' + fileURL + ': ' + step + ': ' + error); this.onExifGiven_(fileURL, {}); }; FileManager.prototype.onExifReaderMessage_ = function(event) { var data = event.data; var self = this; function fwd(methodName, args) { self[methodName].apply(self, args) }; switch (data.verb) { case 'log': console.log.apply(console, ['exif:'].concat(data.arguments)); break; case 'give-exif': fwd('onExifGiven_', data.arguments); break; case 'give-exif-error': fwd('onExifError_', data.arguments); break; default: console.log('Unknown message from exif reader: ' + data.verb, data); break; } }; FileManager.prototype.onTasksFound_ = function(tasksList) { this.taskButtons_.innerHTML = ''; for (var i = 0; i < tasksList.length; i++) { var task = tasksList[i]; // Tweak images, titles of internal tasks. var task_parts = task.taskId.split('|'); if (task_parts[0] == this.getExtensionId_()) { if (task_parts[1] == 'preview') { // TODO(serya): This hack needed until task.iconUrl get working // (see GetFileTasksFileBrowserFunction::RunImpl). task.iconUrl = chrome.extension.getURL('images/icon_preview_16x16.png'); task.title = str('PREVIEW_IMAGE'); } else if (task_parts[1] == 'play') { task.iconUrl = chrome.extension.getURL('images/icon_play_16x16.png'); task.title = str('PLAY_MEDIA').replace("&", ""); } else if (task_parts[1] == 'enqueue') { task.iconUrl = chrome.extension.getURL('images/icon_add_to_queue_16x16.png'); task.title = str('ENQUEUE'); } } var button = this.document_.createElement('button'); button.addEventListener('click', this.onTaskButtonClicked_.bind(this)); button.className = 'task-button'; button.task = task; var img = this.document_.createElement('img'); img.src = task.iconUrl; button.appendChild(img); button.appendChild(this.document_.createTextNode(task.title)); this.taskButtons_.appendChild(button); } }; FileManager.prototype.getExtensionId_ = function() { return chrome.extension.getURL('').split('/')[2]; }; FileManager.prototype.onTaskButtonClicked_ = function(event) { // Check internal tasks first. var task_parts = event.srcElement.task.taskId.split('|'); if (task_parts[0] == this.getExtensionId_()) { if (task_parts[1] == 'preview') { g_slideshow_data = this.selection.urls; chrome.tabs.create({url: "slideshow.html"}); } else if (task_parts[1] == 'play') { chrome.fileBrowserPrivate.viewFiles(this.selection.urls, event.srcElement.task.taskId); } else if (task_parts[1] == 'enqueue') { chrome.fileBrowserPrivate.viewFiles(this.selection.urls, event.srcElement.task.taskId); } return; } chrome.fileBrowserPrivate.executeTask(event.srcElement.task.taskId, this.selection.urls); } /** * Update the breadcrumb display to reflect the current directory. */ FileManager.prototype.updateBreadcrumbs_ = function() { var bc = this.dialogDom_.querySelector('.breadcrumbs'); bc.innerHTML = ''; var fullPath = this.currentDirEntry_.fullPath.replace(/\/$/, ''); var pathNames = fullPath.split('/'); var path = ''; for (var i = 0; i < pathNames.length; i++) { var pathName = pathNames[i]; path += pathName + '/'; var div = this.document_.createElement('div'); div.className = 'breadcrumb-path'; if (i <= 1) { // i == 0: root directory itself, i == 1: the files it contains. div.textContent = this.getLabelForRootPath_(pathName); } else { div.textContent = pathName; } div.path = path; div.addEventListener('click', this.onBreadcrumbClick_.bind(this)); bc.appendChild(div); if (i == pathNames.length - 1) { div.classList.add('breadcrumb-last'); } else { var spacer = this.document_.createElement('div'); spacer.className = 'breadcrumb-spacer'; spacer.textContent = RIGHT_TRIANGLE; bc.appendChild(spacer); } } }; /** * Update the preview panel to display a given entry. * * The selection summary line is handled by the onSelectionSummarized handler * rather than this function, because summarization may not complete quickly. */ FileManager.prototype.updatePreview_ = function() { // Clear the preview image first, in case the thumbnail takes long to load. this.previewImage_.src = ''; // The transparent-background class is used to display the checkerboard // background for image thumbnails. We don't want to display it for // non-thumbnail preview images. this.previewImage_.classList.remove('transparent-background'); // The multiple-selected class indicates that more than one entry is // selcted. this.previewImage_.classList.remove('multiple-selected'); if (!this.selection.totalCount) { this.previewFilename_.textContent = ''; return; } var previewName = this.selection.leadEntry.name; if (this.currentDirEntry_.name == '') previewName = this.getLabelForRootPath_(previewName); this.previewFilename_.textContent = previewName; var iconType = getIconType(this.selection.leadEntry); if (iconType == 'image') { if (fileManager.selection.totalCount > 1) this.previewImage_.classList.add('multiple-selected'); } var self = this; var leadEntry = this.selection.leadEntry; this.getThumbnailURL(this.selection.leadEntry, function(iconType, url) { if (self.selection.leadEntry != leadEntry) { // Selection has changed since we asked, nevermind. return; } if (url) { self.previewImage_.src = url; if (iconType == 'image') self.previewImage_.classList.add('transparent-background'); } else { self.previewImage_.src = previewArt['unknown']; } }); }; FileManager.prototype.cacheExifMetadata_ = function(entry, callback) { var url = entry.toURL(); var cacheValue = this.exifCache_[url]; if (!cacheValue) { // This is the first time anyone's asked, go get it. this.exifCache_[url] = [callback]; this.exifReader.postMessage({verb: 'get-exif', arguments: [entry.toURL()]}); return; } if (cacheValue instanceof Array) { // Something is already pending, add to the list of observers. cacheValue.push(callback); return; } if (cacheValue instanceof Object) { // We already know the answer, let the caller know in a fresh call stack. setTimeout(function() { callback(cacheValue) }); return; } console.error('Unexpected exif cache value:' + cacheValue); }; FileManager.prototype.getThumbnailURL = function(entry, callback) { if (!entry) return; var iconType = getIconType(entry); if (iconType != 'image') { // Not an image, display a canned clip-art graphic. if (!(iconType in previewArt)) iconType = 'unknown'; setTimeout(function() { callback(iconType, previewArt[iconType]) }); return; } if (ENABLE_EXIF_READER) { if (entry.name.match(/\.jpe?g$/i)) { // File is a jpg image, fetch the exif thumbnail. this.cacheExifMetadata_(entry, function(metadata) { callback(iconType, metadata.thumbnailURL || entry.toURL()); }); return; } } // File is some other kind of image, just return the url to the whole // thing. setTimeout(function() { callback(iconType, entry.toURL()) }); }; /** * Change the current directory. * * Dispatches the 'directory-changed' event when the directory is successfully * changed. * * @param {string} path The absolute path to the new directory. * @param {bool} opt_saveHistory Save this in the history stack (defaults * to true). */ FileManager.prototype.changeDirectory = function(path, opt_saveHistory) { var self = this; if (arguments.length == 1) { opt_saveHistory = true; } else { opt_saveHistory = !!opt_saveHistory; } function onPathFound(dirEntry) { if (self.currentDirEntry_ && self.currentDirEntry_.fullPath == dirEntry.fullPath) { // Directory didn't actually change. return; } var e = new cr.Event('directory-changed'); e.previousDirEntry = self.currentDirEntry_; e.newDirEntry = dirEntry; e.saveHistory = opt_saveHistory; self.currentDirEntry_ = dirEntry; self.dispatchEvent(e); }; if (path == '/') return onPathFound(this.filesystem_.root); this.filesystem_.root.getDirectory( path, {create: false}, onPathFound, function(err) { console.error('Error changing directory to: ' + path + ', ' + err); if (!self.currentDirEntry_) { // If we've never successfully changed to a directory, force them // to the root. self.changeDirectory('/'); } }); }; FileManager.prototype.deleteEntries = function(entries) { if (!window.confirm(str('CONFIRM_DELETE'))) return; var count = entries.length; var self = this; function onDelete() { if (--count == 0) self.rescanDirectory_(); } for (var i = 0; i < entries.length; i++) { var entry = entries[i]; if (entry.isFile) { entry.remove( onDelete, util.flog('Error deleting file: ' + entry.fullPath, onDelete)); } else { entry.removeRecursively( onDelete, util.flog('Error deleting folder: ' + entry.fullPath, onDelete)); } } }; /** * Invoked by the table dataModel after a sort completes. * * We use this hook to make sure selected files stay visible after a sort. */ FileManager.prototype.onDataModelSorted_ = function() { var i = this.currentList_.selectionModel.leadIndex; this.currentList_.scrollIntoView(i); } /** * Update the selection summary UI when the selection summarization completes. */ FileManager.prototype.onSelectionSummarized_ = function() { if (this.selection.totalCount == 0) { this.previewSummary_.textContent = str('NOTHING_SELECTED'); } else if (this.selection.totalCount == 1) { this.previewSummary_.textContent = strf('ONE_FILE_SELECTED', cr.locale.bytesToSi(this.selection.bytes)); } else { this.previewSummary_.textContent = strf('MANY_FILES_SELECTED', this.selection.totalCount, cr.locale.bytesToSi(this.selection.bytes)); } }; /** * Handle a click event on a breadcrumb element. * * @param {Event} event The click event. */ FileManager.prototype.onBreadcrumbClick_ = function(event) { this.changeDirectory(event.srcElement.path); }; FileManager.prototype.onCheckboxMouseDownUp_ = function(event) { // If exactly one file is selected and its checkbox is *not* clicked, // then this should be treated as a "normal" click (ie. the previous // selection should be cleared). if (this.selection.totalCount == 1 && this.selection.entries[0].isFile) { var selectedIndex = this.selection.indexes[0]; var listItem = this.currentList_.getListItemByIndex(selectedIndex); var checkbox = listItem.querySelector('input[type="checkbox"]'); if (!checkbox.checked) return; } // Otherwise, treat clicking on a checkbox the same as a ctrl-click. // The default properties of event.ctrlKey make it read-only, but // don't prevent deletion, so we delete first, then set it true. delete event.ctrlKey; event.ctrlKey = true; }; FileManager.prototype.onCheckboxClick_ = function(event) { if (event.shiftKey) { // Something about the timing of shift-clicks causes the checkbox // to get selected and then very quickly unselected. It appears that // we forcibly select the checkbox as part of onSelectionChanged, and // then the default action of this click event fires and toggles the // checkbox back off. // // Since we're going to force checkboxes into the correct state for any // multi-selection, we can prevent this shift click from toggling the // checkbox and avoid the trouble. event.preventDefault(); } }; /** * Update the UI when the selection model changes. * * @param {cr.Event} event The change event. */ FileManager.prototype.onSelectionChanged_ = function(event) { var selectable; this.summarizeSelection_(); this.updateOkButton_(); this.updatePreview_(); var self = this; setTimeout(function() { self.onSelectionChangeComplete_(event) }, 0); }; FileManager.prototype.onSelectionChangeComplete_ = function(event) { if (!this.showCheckboxes_) return; for (var i = 0; i < event.changes.length; i++) { // Turn off any checkboxes for items that are no longer selected. var selectedIndex = event.changes[i].index; var listItem = this.currentList_.getListItemByIndex(selectedIndex); if (!listItem) { // When changing directories, we get notified about list items // that are no longer there. continue; } if (!event.changes[i].selected) { var checkbox = listItem.querySelector('input[type="checkbox"]'); checkbox.checked = false; } } if (this.selection.fileCount > 1) { // If more than one file is selected, make sure all checkboxes are lit // up. for (var i = 0; i < this.selection.entries.length; i++) { if (!this.selection.entries[i].isFile) continue; var selectedIndex = this.selection.indexes[i]; var listItem = this.currentList_.getListItemByIndex(selectedIndex); if (listItem) listItem.querySelector('input[type="checkbox"]').checked = true; } } }; FileManager.prototype.updateOkButton_ = function(event) { if (this.dialogType_ == FileManager.DialogType.SELECT_FOLDER) { selectable = this.selection.directoryCount == 1 && this.selection.fileCount == 0; } else if (this.dialogType_ == FileManager.DialogType.SELECT_OPEN_FILE) { selectable = (this.selection.directoryCount == 0 && this.selection.fileCount == 1); } else if (this.dialogType_ == FileManager.DialogType.SELECT_OPEN_MULTI_FILE) { selectable = (this.selection.directoryCount == 0 && this.selection.fileCount >= 1); } else if (this.dialogType_ == FileManager.DialogType.SELECT_SAVEAS_FILE) { if (this.selection.leadEntry && this.selection.leadEntry.isFile) this.filenameInput_.value = this.selection.leadEntry.name; if (this.currentDirEntry_.fullPath == '/' || this.currentDirEntry_.fullPath == MEDIA_DIRECTORY) { // Nothing can be saved in to the root or media/ directories. selectable = false; } else { selectable = !!this.filenameInput_.value; } } else if (this.dialogType_ == FileManager.DialogType.FULL_PAGE) { // No "select" buttons on the full page UI. selectable = true; } else { throw new Error('Unknown dialog type'); } this.okButton_.disabled = !selectable; }; /** * Handle a double-click event on an entry in the detail list. * * @param {Event} event The click event. */ FileManager.prototype.onDetailDoubleClick_ = function(event) { if (this.renameInput_.currentEntry) { // Don't pay attention to double clicks during a rename. return; } var i = this.currentList_.selectionModel.leadIndex; var entry = this.dataModel_.item(i); if (entry.isDirectory) return this.changeDirectory(entry.fullPath); if (!this.okButton_.disabled) this.onOk_(); }; /** * Update the UI when the current directory changes. * * @param {cr.Event} event The directory-changed event. */ FileManager.prototype.onDirectoryChanged_ = function(event) { if (event.saveHistory) { history.pushState(this.currentDirEntry_.fullPath, this.currentDirEntry_.fullPath, location.href); } this.updateOkButton_(); // New folder should never be enabled in the root or media/ directories. this.newFolderButton_.disabled = (this.currentDirEntry_.fullPath == '/' || this.currentDirEntry_.fullPath == MEDIA_DIRECTORY); this.document_.title = this.currentDirEntry_.fullPath; this.rescanDirectory_(); }; /** * Update the UI when a disk is mounted or unmounted. * * @param {string} path The path that has been mounted or unmounted. */ FileManager.prototype.onDiskChanged_ = function(event) { if (event.eventType == 'added') { this.changeDirectory(event.volumeInfo.mountPath); } else if (event.eventType == 'removed') { if (this.currentDirEntry_ && isParentPath(event.volumeInfo.mountPath, this.currentDirEntry_.fullPath)) { this.changeDirectory(getParentPath(event.volumeInfo.mountPath)); } } }; /** * Rescan the current directory, refreshing the list. * * @param {function()} opt_callback Optional function to invoke when the * rescan is complete. */ FileManager.prototype.rescanDirectory_ = function(opt_callback) { var self = this; var reader; function onReadSome(entries) { if (entries.length == 0) { if (self.dataModel_.sortStatus.field != 'name') self.dataModel_.updateIndex(0); if (opt_callback) opt_callback(); return; } // Splice takes the to-be-spliced-in array as individual parameters, // rather than as an array, so we need to perform some acrobatics... var spliceArgs = [].slice.call(entries); // Hide files that start with a dot ('.'). // TODO(rginda): User should be able to override this. Support for other // commonly hidden patterns might be nice too. if (self.filterFiles_) { spliceArgs = spliceArgs.filter(function(e) { return e.name.substr(0, 1) != '.'; }); } spliceArgs.unshift(0, 0); // index, deleteCount self.dataModel_.splice.apply(self.dataModel_, spliceArgs); // Keep reading until entries.length is 0. reader.readEntries(onReadSome); }; this.lastLabelClick_ = null; // Clear the table first. this.dataModel_.splice(0, this.dataModel_.length); this.updateBreadcrumbs_(); if (this.currentDirEntry_.fullPath != '/') { // If not the root directory, just read the contents. reader = this.currentDirEntry_.createReader(); reader.readEntries(onReadSome); return; } // Otherwise, use the provided list of root subdirectories, since the // real local filesystem root directory (the one we use outside the // harness) can't be enumerated yet. var spliceArgs = [].slice.call(this.rootEntries_); spliceArgs.unshift(0, 0); // index, deleteCount self.dataModel_.splice.apply(self.dataModel_, spliceArgs); self.dataModel_.updateIndex(0); if (opt_callback) opt_callback(); }; FileManager.prototype.findListItem_ = function(event) { var node = event.srcElement; while (node) { if (node.tagName == 'LI') break; node = node.parentNode; } return node; }; FileManager.prototype.onGridMouseDown_ = function(event) { this.updateCommands_(); if (this.allowRenameClick_(event, event.srcElement.parentNode)) { event.preventDefault(); this.initiateRename_(event.srcElement); } if (event.button != 1) return; var li = this.findListItem_(event); if (!li) return; }; FileManager.prototype.onTableMouseDown_ = function(event) { this.updateCommands_(); if (this.allowRenameClick_(event, event.srcElement.parentNode.parentNode)) { event.preventDefault(); this.initiateRename_(event.srcElement); } if (event.button != 1) return; var li = this.findListItem_(event); if (!li) { console.log('li not found', event); return; } }; /** * Determine whether or not a click should initiate a rename. * * Renames can happen on mouse click if the user clicks on a label twice, * at least a half second apart. */ FileManager.prototype.allowRenameClick_ = function(event, row) { if (this.dialogType_ != FileManager.DialogType.FULL_PAGE || this.currentDirEntry_.name == '') { // Renaming only enabled for full-page mode, outside of the root // directory. return false; } // Rename already in progress. if (this.renameInput_.currentEntry) return false; // Didn't click on the label. if (event.srcElement.className != 'filename-label') return false; // Wrong button or using a keyboard modifier. if (event.button != 0 || event.shiftKey || event.metaKey || event.altKey) { this.lastLabelClick_ = null; return false; } var now = new Date(); this.lastLabelClick_ = this.lastLabelClick_ || now; var delay = now - this.lastLabelClick_; if (!row.selected || delay < 500) return false; this.lastLabelClick_ = now; return true; }; FileManager.prototype.initiateRename_= function(label) { var input = this.renameInput_; window.label = label; input.value = label.textContent; input.style.top = label.offsetTop + 'px'; input.style.left = label.offsetLeft + 'px'; input.style.width = label.clientWidth + 'px'; label.parentNode.appendChild(input); input.focus(); var selectionEnd = input.value.lastIndexOf('.'); if (selectionEnd == -1) { input.select(); } else { input.selectionStart = 0; input.selectionEnd = selectionEnd; } // This has to be set late in the process so we don't handle spurious // blur events. input.currentEntry = label.entry; }; FileManager.prototype.onRenameInputKeyDown_ = function(event) { if (!this.renameInput_.currentEntry) return; switch (event.keyCode) { case 27: // Escape this.cancelRename_(); event.preventDefault(); break; case 13: // Enter this.commitRename_(); event.preventDefault(); break; } }; FileManager.prototype.onRenameInputBlur_ = function(event) { if (this.renameInput_.currentEntry) this.cancelRename_(); }; FileManager.prototype.commitRename_ = function() { var entry = this.renameInput_.currentEntry; var newName = this.renameInput_.value; this.renameInput_.currentEntry = null; this.lastLabelClick_ = null; if (this.renameInput_.parentNode) this.renameInput_.parentNode.removeChild(this.renameInput_); var self = this; function onSuccess() { self.rescanDirectory_(function () { for (var i = 0; i < self.dataModel_.length; i++) { if (self.dataModel_.item(i).name == newName) { self.currentList_.selectionModel.selectedIndex = i; self.currentList_.scrollIndexIntoView(i); self.currentList_.focus(); return; } } }); } function onError(err) { window.alert(strf('ERROR_RENAMING', entry.name, util.getFileErrorMnemonic(err.code))); } entry.moveTo(this.currentDirEntry_, newName, onSuccess, onError); }; FileManager.prototype.cancelRename_ = function(event) { this.renameInput_.currentEntry = null; this.lastLabelClick_ = null; if (this.renameInput_.parentNode) this.renameInput_.parentNode.removeChild(this.renameInput_); }; FileManager.prototype.onFilenameInputKeyUp_ = function(event) { this.okButton_.disabled = this.filenameInput_.value.length == 0; if (event.keyCode == 13 /* Enter */ && !this.okButton_.disabled) this.onOk_(); }; FileManager.prototype.onFilenameInputFocus_ = function(event) { var input = this.filenameInput_; // On focus we want to select everything but the extension, but // Chrome will select-all after the focus event completes. We // schedule a timeout to alter the focus after that happens. setTimeout(function() { var selectionEnd = input.value.lastIndexOf('.'); if (selectionEnd == -1) { input.select(); } else { input.selectionStart = 0; input.selectionEnd = selectionEnd; } }, 0); }; FileManager.prototype.onNewFolderButtonClick_ = function(event) { var name = ''; while (1) { name = window.prompt(str('NEW_FOLDER_PROMPT'), name); if (!name) return; if (name.indexOf('/') == -1) break; alert(strf('ERROR_INVALID_FOLDER_CHARACTER', '/')); } var self = this; function onSuccess(dirEntry) { self.rescanDirectory_(function () { for (var i = 0; i < self.dataModel_.length; i++) { if (self.dataModel_.item(i).name == dirEntry.name) { self.currentList_.selectionModel.selectedIndex = i; self.currentList_.scrollIndexIntoView(i); self.currentList_.focus(); return; } } }); } function onError(err) { window.alert(strf('ERROR_CREATING_FOLDER', name, util.getFileErrorMnemonic(err.code))); } this.currentDirEntry_.getDirectory(name, {create: true, exclusive: true}, onSuccess, onError); }; FileManager.prototype.onDetailViewButtonClick_ = function(event) { this.setListType(FileManager.ListType.DETAIL); }; FileManager.prototype.onThumbnailViewButtonClick_ = function(event) { this.setListType(FileManager.ListType.THUMBNAIL); }; FileManager.prototype.onKeyDown_ = function(event) { if (event.srcElement.tagName == 'INPUT') return; switch (event.keyCode) { case 8: // Backspace => Up one directory. event.preventDefault(); var path = this.currentDirEntry_.fullPath; if (path && path != '/') { var path = path.replace(/\/[^\/]+$/, ''); this.changeDirectory(path || '/'); } break; case 13: // Enter => Change directory or complete dialog. if (this.selection.totalCount == 1 && this.selection.leadEntry.isDirectory && this.dialogType_ != FileManager.SELECT_FOLDER) { this.changeDirectory(this.selection.leadEntry.fullPath); } else if (!this.okButton_.disabled) { this.onOk_(); } break; case 32: // Ctrl-Space => New Folder. if (this.newFolderButton_.style.display != 'none' && event.ctrlKey) { event.preventDefault(); this.onNewFolderButtonClick_(); } break; case 190: // Ctrl-. => Toggle filter files. if (event.ctrlKey) { this.filterFiles_ = !this.filterFiles_; this.rescanDirectory_(); } break; case 46: // Delete. if (this.dialogType_ == FileManager.DialogType.FULL_PAGE && this.selection.totalCount > 0) { event.preventDefault(); this.deleteEntries(this.selection.entries); } break; } }; /** * Handle a click of the cancel button. Closes the window. * * @param {Event} event The click event. */ FileManager.prototype.onCancel_ = function(event) { chrome.fileBrowserPrivate.cancelDialog(); }; /** * Handle a click of the ok button. * * The ok button has different UI labels depending on the type of dialog, but * in code it's always referred to as 'ok'. * * @param {Event} event The click event. */ FileManager.prototype.onOk_ = function(event) { var currentDirUrl = this.currentDirEntry_.toURL(); if (currentDirUrl.charAt(currentDirUrl.length - 1) != '/') currentDirUrl += '/'; if (this.dialogType_ == FileManager.DialogType.SELECT_SAVEAS_FILE) { // Save-as doesn't require a valid selection from the list, since // we're going to take the filename from the text input. var filename = this.filenameInput_.value; if (!filename) throw new Error('Missing filename!'); chrome.fileBrowserPrivate.selectFile(currentDirUrl + encodeURI(filename), 0); // Window closed by above call. return; } var ary = []; var selectedIndexes = this.currentList_.selectionModel.selectedIndexes; // All other dialog types require at least one selected list item. // The logic to control whether or not the ok button is enabled should // prevent us from ever getting here, but we sanity check to be sure. if (!selectedIndexes.length) throw new Error('Nothing selected!'); for (var i = 0; i < selectedIndexes.length; i++) { var entry = this.dataModel_.item(selectedIndexes[i]); if (!entry) { console.log('Error locating selected file at index: ' + i); continue; } ary.push(currentDirUrl + encodeURI(entry.name)); } // Multi-file selection has no other restrictions. if (this.dialogType_ == FileManager.DialogType.SELECT_OPEN_MULTI_FILE) { chrome.fileBrowserPrivate.selectFiles(ary); // Window closed by above call. return; } // In full screen mode, open all files for vieweing. if (this.dialogType_ == FileManager.DialogType.FULL_PAGE) { chrome.fileBrowserPrivate.viewFiles(ary, "default"); // Window stays open. return; } // Everything else must have exactly one. if (ary.length > 1) throw new Error('Too many files selected!'); if (this.dialogType_ == FileManager.DialogType.SELECT_FOLDER) { if (!this.selection.leadEntry.isDirectory) throw new Error('Selected entry is not a folder!'); } else if (this.dialogType_ == FileManager.DialogType.SELECT_OPEN_FILE) { if (!this.selection.leadEntry.isFile) throw new Error('Selected entry is not a file!'); } chrome.fileBrowserPrivate.selectFile(ary[0], 0); // Window closed by above call. }; })();