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 * A navigation list item. 9 * @constructor 10 * @extends {HTMLLIElement} 11 */ 12var NavigationListItem = cr.ui.define('li'); 13 14NavigationListItem.prototype = { 15 __proto__: HTMLLIElement.prototype, 16 get modelItem() { return this.modelItem_; } 17}; 18 19/** 20 * Decorate the item. 21 */ 22NavigationListItem.prototype.decorate = function() { 23 // decorate() may be called twice: from the constructor and from 24 // List.createItem(). This check prevents double-decorating. 25 if (this.className) 26 return; 27 28 this.className = 'root-item'; 29 this.setAttribute('role', 'option'); 30 31 this.iconDiv_ = cr.doc.createElement('div'); 32 this.iconDiv_.className = 'volume-icon'; 33 this.appendChild(this.iconDiv_); 34 35 this.label_ = cr.doc.createElement('div'); 36 this.label_.className = 'root-label entry-name'; 37 this.appendChild(this.label_); 38 39 cr.defineProperty(this, 'lead', cr.PropertyKind.BOOL_ATTR); 40 cr.defineProperty(this, 'selected', cr.PropertyKind.BOOL_ATTR); 41}; 42 43/** 44 * Associate a model with this item. 45 * @param {NavigationModelItem} modelItem NavigationModelItem of this item. 46 */ 47NavigationListItem.prototype.setModelItem = function(modelItem) { 48 if (this.modelItem_) 49 console.warn('NavigationListItem.setModelItem should be called only once.'); 50 51 this.modelItem_ = modelItem; 52 53 var typeIcon; 54 if (modelItem.isVolume) { 55 if (modelItem.volumeInfo.volumeType == 'provided') { 56 var backgroundImage = '-webkit-image-set(' + 57 'url(chrome://extension-icon/' + modelItem.volumeInfo.extensionId + 58 '/24/1) 1x, ' + 59 'url(chrome://extension-icon/' + modelItem.volumeInfo.extensionId + 60 '/48/1) 2x);'; 61 // The icon div is not yet added to DOM, therefore it is impossible to 62 // use style.backgroundImage. 63 this.iconDiv_.setAttribute( 64 'style', 'background-image: ' + backgroundImage); 65 } 66 typeIcon = modelItem.volumeInfo.volumeType; 67 } else if (modelItem.isShortcut) { 68 // Shortcuts are available for Drive only. 69 typeIcon = VolumeManagerCommon.VolumeType.DRIVE; 70 } 71 72 this.iconDiv_.setAttribute('volume-type-icon', typeIcon); 73 74 if (modelItem.isVolume) { 75 this.iconDiv_.setAttribute( 76 'volume-subtype', modelItem.volumeInfo.deviceType); 77 } 78 79 this.label_.textContent = modelItem.label; 80 81 if (modelItem.isVolume && 82 (modelItem.volumeInfo.volumeType === 83 VolumeManagerCommon.VolumeType.ARCHIVE || 84 modelItem.volumeInfo.volumeType === 85 VolumeManagerCommon.VolumeType.REMOVABLE || 86 modelItem.volumeInfo.volumeType === 87 VolumeManagerCommon.VolumeType.PROVIDED)) { 88 this.eject_ = cr.doc.createElement('div'); 89 // Block other mouse handlers. 90 this.eject_.addEventListener( 91 'mouseup', function(event) { event.stopPropagation() }); 92 this.eject_.addEventListener( 93 'mousedown', function(event) { event.stopPropagation() }); 94 95 this.eject_.className = 'root-eject'; 96 this.eject_.addEventListener('click', function(event) { 97 event.stopPropagation(); 98 cr.dispatchSimpleEvent(this, 'eject'); 99 }.bind(this)); 100 101 this.appendChild(this.eject_); 102 } 103}; 104 105/** 106 * Associate a context menu with this item. 107 * @param {cr.ui.Menu} menu Menu this item. 108 */ 109NavigationListItem.prototype.maybeSetContextMenu = function(menu) { 110 if (!this.modelItem_) { 111 console.error('NavigationListItem.maybeSetContextMenu must be called ' + 112 'after setModelItem().'); 113 return; 114 } 115 116 // The context menu is shown on the following items: 117 // - Removable and Archive volumes 118 // - Folder shortcuts 119 if (this.modelItem_.isVolume && (this.modelItem_.volumeInfo.volumeType === 120 VolumeManagerCommon.VolumeType.REMOVABLE || 121 this.modelItem_.volumeInfo.volumeType === 122 VolumeManagerCommon.VolumeType.ARCHIVE || 123 this.modelItem_.volumeInfo.volumeType === 124 VolumeManagerCommon.VolumeType.PROVIDED) || 125 this.modelItem_.isShortcut) { 126 cr.ui.contextMenuHandler.setContextMenu(this, menu); 127 } 128}; 129 130/** 131 * A navigation list. 132 * @constructor 133 * @extends {cr.ui.List} 134 */ 135function NavigationList() { 136} 137 138/** 139 * NavigationList inherits cr.ui.List. 140 */ 141NavigationList.prototype = { 142 __proto__: cr.ui.List.prototype, 143 144 set dataModel(dataModel) { 145 if (!this.onListContentChangedBound_) 146 this.onListContentChangedBound_ = this.onListContentChanged_.bind(this); 147 148 if (this.dataModel_) { 149 this.dataModel_.removeEventListener( 150 'change', this.onListContentChangedBound_); 151 this.dataModel_.removeEventListener( 152 'permuted', this.onListContentChangedBound_); 153 } 154 155 var parentSetter = cr.ui.List.prototype.__lookupSetter__('dataModel'); 156 parentSetter.call(this, dataModel); 157 158 // This must be placed after the parent method is called, in order to make 159 // it sure that the list was changed. 160 dataModel.addEventListener('change', this.onListContentChangedBound_); 161 dataModel.addEventListener('permuted', this.onListContentChangedBound_); 162 }, 163 164 get dataModel() { 165 return this.dataModel_; 166 }, 167 168 // TODO(yoshiki): Add a setter of 'directoryModel'. 169}; 170 171/** 172 * @param {HTMLElement} el Element to be DirectoryItem. 173 * @param {VolumeManagerWrapper} volumeManager The VolumeManager of the system. 174 * @param {DirectoryModel} directoryModel Current DirectoryModel. 175 * folders. 176 */ 177NavigationList.decorate = function(el, volumeManager, directoryModel) { 178 el.__proto__ = NavigationList.prototype; 179 el.decorate(volumeManager, directoryModel); 180}; 181 182/** 183 * @param {VolumeManagerWrapper} volumeManager The VolumeManager of the system. 184 * @param {DirectoryModel} directoryModel Current DirectoryModel. 185 */ 186NavigationList.prototype.decorate = function(volumeManager, directoryModel) { 187 cr.ui.List.decorate(this); 188 this.__proto__ = NavigationList.prototype; 189 190 this.directoryModel_ = directoryModel; 191 this.volumeManager_ = volumeManager; 192 this.selectionModel = new cr.ui.ListSingleSelectionModel(); 193 194 this.directoryModel_.addEventListener('directory-changed', 195 this.onCurrentDirectoryChanged_.bind(this)); 196 this.selectionModel.addEventListener( 197 'change', this.onSelectionChange_.bind(this)); 198 this.selectionModel.addEventListener( 199 'beforeChange', this.onBeforeSelectionChange_.bind(this)); 200 201 this.scrollBar_ = new ScrollBar(); 202 this.scrollBar_.initialize(this.parentNode, this); 203 204 // Keeps track of selected model item to detect if it is changed actually. 205 this.currentModelItem_ = null; 206 207 // Overriding default role 'list' set by cr.ui.List.decorate() to 'listbox' 208 // role for better accessibility on ChromeOS. 209 this.setAttribute('role', 'listbox'); 210 211 var self = this; 212 this.itemConstructor = function(modelItem) { 213 return self.renderRoot_(modelItem); 214 }; 215}; 216 217/** 218 * This overrides cr.ui.List.measureItem(). 219 * In the method, a temporary element is added/removed from the list, and we 220 * need to omit animations for such temporary items. 221 * 222 * @param {ListItem=} opt_item The list item to be measured. 223 * @return {{height: number, marginTop: number, marginBottom:number, 224 * width: number, marginLeft: number, marginRight:number}} Size. 225 * @override 226 */ 227NavigationList.prototype.measureItem = function(opt_item) { 228 this.measuringTemporaryItemNow_ = true; 229 var result = cr.ui.List.prototype.measureItem.call(this, opt_item); 230 this.measuringTemporaryItemNow_ = false; 231 return result; 232}; 233 234/** 235 * Creates an element of a navigation list. This method is called from 236 * cr.ui.List internally. 237 * 238 * @param {NavigationModelItem} modelItem NavigationModelItem to be rendered. 239 * @return {NavigationListItem} Rendered element. 240 * @private 241 */ 242NavigationList.prototype.renderRoot_ = function(modelItem) { 243 var item = new NavigationListItem(); 244 item.setModelItem(modelItem); 245 246 var handleClick = function() { 247 if (!item.selected) 248 return; 249 this.activateModelItem_(item.modelItem); 250 // On clicking the root item, clears the selection. 251 this.directoryModel_.clearSelection(); 252 }.bind(this); 253 item.addEventListener('click', handleClick); 254 255 var handleEject = function() { 256 var unmountCommand = cr.doc.querySelector('command#unmount'); 257 // Let's make sure 'canExecute' state of the command is properly set for 258 // the root before executing it. 259 unmountCommand.canExecuteChange(item); 260 unmountCommand.execute(item); 261 }; 262 item.addEventListener('eject', handleEject); 263 264 if (this.contextMenu_) 265 item.maybeSetContextMenu(this.contextMenu_); 266 267 return item; 268}; 269 270/** 271 * Sets a context menu. Context menu is enabled only on archive and removable 272 * volumes as of now. 273 * 274 * @param {cr.ui.Menu} menu Context menu. 275 */ 276NavigationList.prototype.setContextMenu = function(menu) { 277 this.contextMenu_ = menu; 278 279 for (var i = 0; i < this.dataModel.length; i++) { 280 this.getListItemByIndex(i).maybeSetContextMenu(this.contextMenu_); 281 } 282}; 283 284/** 285 * Selects the n-th item from the list. 286 * @param {number} index Item index. 287 * @return {boolean} True for success, otherwise false. 288 */ 289NavigationList.prototype.selectByIndex = function(index) { 290 if (index < 0 || index > this.dataModel.length - 1) 291 return false; 292 293 this.selectionModel.selectedIndex = index; 294 this.activateModelItem_(this.dataModel.item(index)); 295 return true; 296}; 297 298 299/** 300 * Selects the passed item of the model. 301 * @param {NavigationModelItem} modelItem Model item to be activated. 302 * @private 303 */ 304NavigationList.prototype.activateModelItem_ = function(modelItem) { 305 var onEntryResolved = function(entry) { 306 // Changes directory to the model item's root directory if needed. 307 if (!util.isSameEntry(this.directoryModel_.getCurrentDirEntry(), entry)) { 308 metrics.recordUserAction('FolderShortcut.Navigate'); 309 this.directoryModel_.changeDirectoryEntry(entry); 310 } 311 }.bind(this); 312 313 if (modelItem.isVolume) { 314 modelItem.volumeInfo.resolveDisplayRoot( 315 onEntryResolved, 316 function() { 317 // Error, the display root is not available. It may happen on Drive. 318 this.dataModel.onItemNotFoundError(modelItem); 319 }.bind(this)); 320 } else if (modelItem.isShortcut) { 321 // For shortcuts we already have an Entry, but it has to be resolved again 322 // in case, it points to a non-existing directory. 323 var url = modelItem.entry.toURL(); 324 webkitResolveLocalFileSystemURL( 325 url, 326 onEntryResolved, 327 function() { 328 // Error, the entry can't be re-resolved. It may happen for shortcuts 329 // which targets got removed after resolving the Entry during 330 // initialization. 331 this.dataModel.onItemNotFoundError(modelItem); 332 }.bind(this)); 333 } 334}; 335 336/** 337 * Handler before root item change. 338 * @param {Event} event The event. 339 * @private 340 */ 341NavigationList.prototype.onBeforeSelectionChange_ = function(event) { 342 if (event.changes.length == 1 && !event.changes[0].selected) 343 event.preventDefault(); 344}; 345 346/** 347 * Handler for root item being clicked. 348 * @param {Event} event The event. 349 * @private 350 */ 351NavigationList.prototype.onSelectionChange_ = function(event) { 352 var index = this.selectionModel.selectedIndex; 353 if (index < 0 || index > this.dataModel.length - 1) 354 return; 355 356 // If the selected model item is not changed actually, we don't change the 357 // current directory even if the selected index is changed. 358 var modelItem = this.dataModel.item(index); 359 if (modelItem === this.currentModelItem_) 360 return; 361 362 // Remembers the selected model item. 363 this.currentModelItem_ = modelItem; 364 365 // This handler is invoked even when the navigation list itself changes the 366 // selection. In such case, we shouldn't handle the event. 367 if (this.dontHandleSelectionEvent_) 368 return; 369 370 this.activateModelItem_(modelItem); 371}; 372 373/** 374 * Invoked when the current directory is changed. 375 * @param {Event} event The event. 376 * @private 377 */ 378NavigationList.prototype.onCurrentDirectoryChanged_ = function(event) { 379 this.selectBestMatchItem_(); 380}; 381 382/** 383 * Invoked when the content in the data model is changed. 384 * @param {Event} event The event. 385 * @private 386 */ 387NavigationList.prototype.onListContentChanged_ = function(event) { 388 this.selectBestMatchItem_(); 389}; 390 391/** 392 * Synchronizes the volume list selection with the current directory, after 393 * it is changed outside of the volume list. 394 * @private 395 */ 396NavigationList.prototype.selectBestMatchItem_ = function() { 397 var entry = this.directoryModel_.getCurrentDirEntry(); 398 // It may happen that the current directory is not set yet, for update events. 399 if (!entry) 400 return; 401 402 // (1) Select the nearest shortcut directory. 403 var entryURL = entry.toURL(); 404 var bestMatchIndex = -1; 405 var bestMatchSubStringLen = 0; 406 for (var i = 0; i < this.dataModel.length; i++) { 407 var modelItem = this.dataModel.item(i); 408 if (!modelItem.isShortcut) 409 continue; 410 var modelItemURL = modelItem.entry.toURL(); 411 // Relying on URL's format is risky and should be avoided. However, there is 412 // no other way to quickly check if an entry is an ancestor of another one. 413 if (entryURL.indexOf(modelItemURL) === 0) { 414 if (bestMatchSubStringLen < modelItemURL.length) { 415 bestMatchIndex = i; 416 bestMatchSubStringLen = modelItemURL.length; 417 } 418 } 419 } 420 if (bestMatchIndex != -1) { 421 // Don't to invoke the handler of this instance, sets the guard. 422 this.dontHandleSelectionEvent_ = true; 423 this.selectionModel.selectedIndex = bestMatchIndex; 424 this.dontHandleSelectionEvent_ = false; 425 return; 426 } 427 428 // (2) Selects the volume of the current directory. 429 var volumeInfo = this.volumeManager_.getVolumeInfo(entry); 430 if (!volumeInfo) 431 return; 432 for (var i = 0; i < this.dataModel.length; i++) { 433 var modelItem = this.dataModel.item(i); 434 if (!modelItem.isVolume) 435 continue; 436 if (modelItem.volumeInfo === volumeInfo) { 437 // Don't to invoke the handler of this instance, sets the guard. 438 this.dontHandleSelectionEvent_ = true; 439 this.selectionModel.selectedIndex = i; 440 this.dontHandleSelectionEvent_ = false; 441 return; 442 } 443 } 444}; 445