allDownloadsView.js (30199B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this file, 3 * You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 var { XPCOMUtils } = ChromeUtils.importESModule( 6 "resource://gre/modules/XPCOMUtils.sys.mjs" 7 ); 8 9 ChromeUtils.defineESModuleGetters(this, { 10 BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs", 11 Downloads: "resource://gre/modules/Downloads.sys.mjs", 12 DownloadsCommon: 13 "moz-src:///browser/components/downloads/DownloadsCommon.sys.mjs", 14 DownloadsViewUI: 15 "moz-src:///browser/components/downloads/DownloadsViewUI.sys.mjs", 16 FileUtils: "resource://gre/modules/FileUtils.sys.mjs", 17 NetUtil: "resource://gre/modules/NetUtil.sys.mjs", 18 PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", 19 }); 20 21 const CLIPBOARD_URL_FLAVORS = ["text/x-moz-url", "text/plain"]; 22 23 /** 24 * A download element shell is responsible for handling the commands and the 25 * displayed data for a single download view element. 26 * 27 * The shell may contain a session download, a history download, or both. When 28 * both a history and a session download are present, the session download gets 29 * priority and its information is displayed. 30 * 31 * On construction, a new richlistitem is created, and can be accessed through 32 * the |element| getter. The shell doesn't insert the item in a richlistbox, the 33 * caller must do it and remove the element when it's no longer needed. 34 * 35 * The caller is also responsible for forwarding status notifications, calling 36 * the onChanged method. 37 * 38 * @param download 39 * The Download object from the DownloadHistoryList. 40 */ 41 function HistoryDownloadElementShell(download) { 42 this._download = download; 43 44 this.element = document.createXULElement("richlistitem"); 45 this.element._shell = this; 46 47 this.element.classList.add("download"); 48 this.element.classList.add("download-state"); 49 } 50 51 HistoryDownloadElementShell.prototype = { 52 /** 53 * Overrides the base getter to return the Download or HistoryDownload object 54 * for displaying information and executing commands in the user interface. 55 */ 56 get download() { 57 return this._download; 58 }, 59 60 onStateChanged() { 61 // Since the state changed, we may need to check the target file again. 62 this._targetFileChecked = false; 63 64 this._updateState(); 65 66 if (this.element.selected) { 67 goUpdateDownloadCommands(); 68 } else { 69 // If a state change occurs in an item that is not currently selected, 70 // this is the only command that may be affected. 71 goUpdateCommand("downloadsCmd_clearDownloads"); 72 } 73 }, 74 75 onChanged() { 76 // There is nothing to do if the item has always been invisible. 77 if (!this.active) { 78 return; 79 } 80 81 let newState = DownloadsCommon.stateOfDownload(this.download); 82 if (this._downloadState !== newState) { 83 this._downloadState = newState; 84 this.onStateChanged(); 85 } else { 86 this._updateStateInner(); 87 } 88 }, 89 _downloadState: null, 90 91 isCommandEnabled(aCommand) { 92 // The only valid command for inactive elements is cmd_delete. 93 if (!this.active && aCommand != "cmd_delete") { 94 return false; 95 } 96 return DownloadsViewUI.DownloadElementShell.prototype.isCommandEnabled.call( 97 this, 98 aCommand 99 ); 100 }, 101 102 downloadsCmd_unblock() { 103 this.confirmUnblock(window, "unblock"); 104 }, 105 downloadsCmd_unblockAndSave() { 106 this.confirmUnblock(window, "unblock"); 107 }, 108 109 downloadsCmd_chooseUnblock() { 110 this.confirmUnblock(window, "chooseUnblock"); 111 }, 112 113 downloadsCmd_chooseOpen() { 114 this.confirmUnblock(window, "chooseOpen"); 115 }, 116 117 // Returns whether or not the download handled by this shell should 118 // show up in the search results for the given term. Both the display 119 // name for the download and the url are searched. 120 matchesSearchTerm(aTerm) { 121 if (!aTerm) { 122 return true; 123 } 124 aTerm = aTerm.toLowerCase(); 125 let displayName = DownloadsViewUI.getDisplayName(this.download); 126 return ( 127 displayName.toLowerCase().includes(aTerm) || 128 (this.download.source.originalUrl || this.download.source.url) 129 .toLowerCase() 130 .includes(aTerm) 131 ); 132 }, 133 134 // Handles double-click and return keypress on the element (the keypress 135 // listener is set in the DownloadsPlacesView object). 136 doDefaultCommand(event) { 137 let command = this.currentDefaultCommandName; 138 if ( 139 command == "downloadsCmd_open" && 140 event && 141 (event.shiftKey || event.ctrlKey || event.metaKey || event.button == 1) 142 ) { 143 // We adjust the command for supported modifiers to suggest where the download may 144 // be opened. 145 let browserWin = BrowserWindowTracker.getTopWindow(); 146 let openWhere = browserWin 147 ? BrowserUtils.whereToOpenLink(event, false, true) 148 : "window"; 149 if (["window", "tabshifted", "tab"].includes(openWhere)) { 150 command += ":" + openWhere; 151 } 152 } 153 154 if (command && this.isCommandEnabled(command)) { 155 this.doCommand(command); 156 } 157 }, 158 159 /** 160 * This method is called by the outer download view, after the controller 161 * commands have already been updated. In case we did not check for the 162 * existence of the target file already, we can do it now and then update 163 * the commands as needed. 164 */ 165 onSelect() { 166 if (!this.active) { 167 return; 168 } 169 170 // If this is a history download for which no target file information is 171 // available, we cannot retrieve information about the target file. 172 if (!this.download.target.path) { 173 return; 174 } 175 176 // Start checking for existence. This may be done twice if onSelect is 177 // called again before the information is collected. 178 if (!this._targetFileChecked) { 179 this.download 180 .refresh() 181 .catch(console.error) 182 .then(() => { 183 // Do not try to check for existence again even if this failed. 184 this._targetFileChecked = true; 185 }); 186 } 187 }, 188 }; 189 Object.setPrototypeOf( 190 HistoryDownloadElementShell.prototype, 191 DownloadsViewUI.DownloadElementShell.prototype 192 ); 193 194 /** 195 * Relays commands from the download.xml binding to the selected items. 196 */ 197 var DownloadsView = { 198 onDownloadButton(event) { 199 event.target.closest("richlistitem")._shell.onButton(); 200 }, 201 202 onDownloadClick() {}, 203 }; 204 205 /** 206 * A Downloads Places View is a places view designed to show a places query 207 * for history downloads alongside the session downloads. 208 * 209 * As we don't use the places controller, some methods implemented by other 210 * places views are not implemented by this view. 211 * 212 * A richlistitem in this view can represent either a past download or a session 213 * download, or both. Session downloads are shown first in the view, and as long 214 * as they exist they "collapses" their history "counterpart" (So we don't show two 215 * items for every download). 216 */ 217 function DownloadsPlacesView( 218 aRichListBox, 219 aActive = true, 220 aSuppressionFlag = DownloadsCommon.SUPPRESS_ALL_DOWNLOADS_OPEN 221 ) { 222 this._richlistbox = aRichListBox; 223 this._richlistbox._placesView = this; 224 window.controllers.insertControllerAt(0, this); 225 226 // Map downloads to their element shells. 227 this._viewItemsForDownloads = new WeakMap(); 228 229 this._searchTerm = ""; 230 231 this._active = aActive; 232 233 // Register as a downloads view. The places data will be initialized by 234 // the places setter. 235 this._initiallySelectedElement = null; 236 this._downloadsData = DownloadsCommon.getData(window.opener || window, true); 237 this._waitingForInitialData = true; 238 this._downloadsData.addView(this); 239 240 // Pause the download indicator as user is interacting with downloads. This is 241 // skipped on about:downloads because it handles this by itself. 242 if (aSuppressionFlag === DownloadsCommon.SUPPRESS_ALL_DOWNLOADS_OPEN) { 243 DownloadsCommon.getIndicatorData(window).attentionSuppressed |= 244 aSuppressionFlag; 245 } 246 247 // Make sure to unregister the view if the window is closed. 248 window.addEventListener( 249 "unload", 250 () => { 251 window.controllers.removeController(this); 252 // Unpause the main window's download indicator. 253 DownloadsCommon.getIndicatorData(window).attentionSuppressed &= 254 ~aSuppressionFlag; 255 this._downloadsData.removeView(this); 256 this.result = null; 257 }, 258 true 259 ); 260 // Resizing the window may change items visibility. 261 window.addEventListener( 262 "resize", 263 () => { 264 this._ensureVisibleElementsAreActive(true); 265 }, 266 true 267 ); 268 } 269 270 DownloadsPlacesView.prototype = { 271 get associatedElement() { 272 return this._richlistbox; 273 }, 274 275 get active() { 276 return this._active; 277 }, 278 set active(val) { 279 this._active = val; 280 if (this._active) { 281 this._ensureVisibleElementsAreActive(true); 282 } 283 }, 284 285 /** 286 * Ensure the custom element contents are created and shown for each 287 * visible element in the list. 288 * 289 * @param debounce whether to use a short timeout rather than running 290 * immediately. The default is running immediately. If you 291 * pass `true`, we'll run on a 10ms timeout. This is used to 292 * avoid running this code lots while scrolling or resizing. 293 */ 294 _ensureVisibleElementsAreActive(debounce = false) { 295 if ( 296 !this.active || 297 (debounce && this._ensureVisibleTimer) || 298 !this._richlistbox.firstChild 299 ) { 300 return; 301 } 302 303 if (debounce) { 304 this._ensureVisibleTimer = setTimeout(() => { 305 this._internalEnsureVisibleElementsAreActive(); 306 }, 10); 307 } else { 308 this._internalEnsureVisibleElementsAreActive(); 309 } 310 }, 311 312 _internalEnsureVisibleElementsAreActive() { 313 // If there are no children, we can't do anything so bail out. 314 // However, avoid clearing the timer because there may be children 315 // when the timer fires. 316 if (!this._richlistbox.firstChild) { 317 // If we were called asynchronously (debounced), we need to delete 318 // the timer variable to ensure we are called again if another 319 // debounced call comes in. 320 delete this._ensureVisibleTimer; 321 return; 322 } 323 324 if (this._ensureVisibleTimer) { 325 clearTimeout(this._ensureVisibleTimer); 326 delete this._ensureVisibleTimer; 327 } 328 329 let rlbRect = this._richlistbox.getBoundingClientRect(); 330 let winUtils = window.windowUtils; 331 let nodes = winUtils.nodesFromRect( 332 rlbRect.left, 333 rlbRect.top, 334 0, 335 rlbRect.width, 336 rlbRect.height, 337 0, 338 true, 339 false, 340 false 341 ); 342 // nodesFromRect returns nodes in z-index order, and for the same z-index 343 // sorts them in inverted DOM order, thus starting from the one that would 344 // be on top. 345 let firstVisibleNode, lastVisibleNode; 346 for (let node of nodes) { 347 if (node.localName === "richlistitem" && node._shell) { 348 node._shell.ensureActive(); 349 // The first visible node is the last match. 350 firstVisibleNode = node; 351 // While the last visible node is the first match. 352 if (!lastVisibleNode) { 353 lastVisibleNode = node; 354 } 355 } 356 } 357 358 // Also activate the first invisible nodes in both boundaries (that is, 359 // above and below the visible area) to ensure proper keyboard navigation 360 // in both directions. 361 let nodeBelowVisibleArea = lastVisibleNode && lastVisibleNode.nextSibling; 362 if (nodeBelowVisibleArea && nodeBelowVisibleArea._shell) { 363 nodeBelowVisibleArea._shell.ensureActive(); 364 } 365 366 let nodeAboveVisibleArea = 367 firstVisibleNode && firstVisibleNode.previousSibling; 368 if (nodeAboveVisibleArea && nodeAboveVisibleArea._shell) { 369 nodeAboveVisibleArea._shell.ensureActive(); 370 } 371 }, 372 373 _place: "", 374 get place() { 375 return this._place; 376 }, 377 set place(val) { 378 if (this._place == val) { 379 // XXXmano: places.js relies on this behavior (see Bug 822203). 380 this.searchTerm = ""; 381 } else { 382 this._place = val; 383 } 384 }, 385 386 get selectedNodes() { 387 return Array.prototype.filter.call( 388 this._richlistbox.selectedItems, 389 element => element._shell.download.placesNode 390 ); 391 }, 392 393 get selectedNode() { 394 let selectedNodes = this.selectedNodes; 395 return selectedNodes.length == 1 ? selectedNodes[0] : null; 396 }, 397 398 get hasSelection() { 399 return !!this.selectedNodes.length; 400 }, 401 402 get controller() { 403 return this._richlistbox.controller; 404 }, 405 406 get searchTerm() { 407 return this._searchTerm; 408 }, 409 set searchTerm(aValue) { 410 if (this._searchTerm != aValue) { 411 // Always clear selection on a new search, since the user is starting a 412 // different workflow. This also solves the fact we could end up 413 // retaining selection on hidden elements. 414 this._richlistbox.clearSelection(); 415 for (let element of this._richlistbox.childNodes) { 416 element.hidden = !element._shell.matchesSearchTerm(aValue); 417 } 418 this._ensureVisibleElementsAreActive(); 419 } 420 this._searchTerm = aValue; 421 }, 422 423 /** 424 * When the view loads, we want to select the first item. 425 * However, because session downloads, for which the data is loaded 426 * asynchronously, always come first in the list, and because the list 427 * may (or may not) already contain history downloads at that point, it 428 * turns out that by the time we can select the first item, the user may 429 * have already started using the view. 430 * To make things even more complicated, in other cases, the places data 431 * may be loaded after the session downloads data. Thus we cannot rely on 432 * the order in which the data comes in. 433 * We work around this by attempting to select the first element twice, 434 * once after the places data is loaded and once when the session downloads 435 * data is done loading. However, if the selection has changed in-between, 436 * we assume the user has already started using the view and give up. 437 */ 438 _ensureInitialSelection() { 439 // Either they're both null, or the selection has not changed in between. 440 if (this._richlistbox.selectedItem == this._initiallySelectedElement) { 441 let firstDownloadElement = this._richlistbox.firstChild; 442 if (firstDownloadElement != this._initiallySelectedElement) { 443 // We may be called before _ensureVisibleElementsAreActive, 444 // therefore, ensure the first item is activated. 445 firstDownloadElement._shell.ensureActive(); 446 this._richlistbox.selectedItem = firstDownloadElement; 447 this._richlistbox.currentItem = firstDownloadElement; 448 this._initiallySelectedElement = firstDownloadElement; 449 } 450 } 451 }, 452 453 /** 454 * DocumentFragment object that contains all the new elements added during a 455 * batch operation, or null if no batch is in progress. 456 * 457 * Since newest downloads are displayed at the top, elements are normally 458 * prepended to the fragment, and then the fragment is prepended to the list. 459 */ 460 batchFragment: null, 461 462 onDownloadBatchStarting() { 463 this.batchFragment = document.createDocumentFragment(); 464 465 this.oldSuppressOnSelect = this._richlistbox.suppressOnSelect; 466 this._richlistbox.suppressOnSelect = true; 467 }, 468 469 onDownloadBatchEnded() { 470 this._richlistbox.suppressOnSelect = this.oldSuppressOnSelect; 471 delete this.oldSuppressOnSelect; 472 473 if (this.batchFragment.childElementCount) { 474 this._prependBatchFragment(); 475 } 476 this.batchFragment = null; 477 478 this._ensureInitialSelection(); 479 this._ensureVisibleElementsAreActive(); 480 goUpdateDownloadCommands(); 481 if (this._waitingForInitialData) { 482 this._waitingForInitialData = false; 483 this._richlistbox.dispatchEvent( 484 new CustomEvent("InitialDownloadsLoaded") 485 ); 486 } 487 }, 488 489 _prependBatchFragment() { 490 // Workaround multiple reflows hang by removing the richlistbox 491 // and adding it back when we're done. 492 493 // Hack for bug 836283: reset xbl fields to their old values after the 494 // binding is reattached to avoid breaking the selection state 495 let xblFields = new Map(); 496 for (let key of Object.getOwnPropertyNames(this._richlistbox)) { 497 let value = this._richlistbox[key]; 498 xblFields.set(key, value); 499 } 500 501 let oldActiveElement = document.activeElement; 502 let parentNode = this._richlistbox.parentNode; 503 let nextSibling = this._richlistbox.nextSibling; 504 parentNode.removeChild(this._richlistbox); 505 this._richlistbox.prepend(this.batchFragment); 506 parentNode.insertBefore(this._richlistbox, nextSibling); 507 if (oldActiveElement && oldActiveElement != document.activeElement) { 508 oldActiveElement.focus(); 509 } 510 511 for (let [key, value] of xblFields) { 512 this._richlistbox[key] = value; 513 } 514 }, 515 516 onDownloadAdded(download, { insertBefore } = {}) { 517 let shell = new HistoryDownloadElementShell(download); 518 this._viewItemsForDownloads.set(download, shell); 519 520 // Since newest downloads are displayed at the top, either prepend the new 521 // element or insert it after the one indicated by the insertBefore option. 522 if (insertBefore) { 523 this._viewItemsForDownloads 524 .get(insertBefore) 525 .element.insertAdjacentElement("afterend", shell.element); 526 } else { 527 (this.batchFragment || this._richlistbox).prepend(shell.element); 528 } 529 530 if (this.searchTerm) { 531 shell.element.hidden = !shell.matchesSearchTerm(this.searchTerm); 532 } 533 534 // Don't update commands and visible elements during a batch change. 535 if (!this.batchFragment) { 536 this._ensureVisibleElementsAreActive(); 537 goUpdateCommand("downloadsCmd_clearDownloads"); 538 } 539 }, 540 541 onDownloadChanged(download) { 542 this._viewItemsForDownloads.get(download).onChanged(); 543 }, 544 545 onDownloadRemoved(download) { 546 let element = this._viewItemsForDownloads.get(download).element; 547 548 // If the element was selected exclusively, select its next 549 // sibling first, if not, try for previous sibling, if any. 550 if ( 551 (element.nextSibling || element.previousSibling) && 552 this._richlistbox.selectedItems && 553 this._richlistbox.selectedItems.length == 1 && 554 this._richlistbox.selectedItems[0] == element 555 ) { 556 this._richlistbox.selectItem( 557 element.nextSibling || element.previousSibling 558 ); 559 } 560 561 this._richlistbox.removeItemFromSelection(element); 562 element.remove(); 563 564 // Don't update commands and visible elements during a batch change. 565 if (!this.batchFragment) { 566 this._ensureVisibleElementsAreActive(); 567 goUpdateCommand("downloadsCmd_clearDownloads"); 568 } 569 }, 570 571 // nsIController 572 supportsCommand(aCommand) { 573 // Firstly, determine if this is a command that we can handle. 574 if (!DownloadsViewUI.isCommandName(aCommand)) { 575 return false; 576 } 577 if ( 578 !(aCommand in this) && 579 !(aCommand in HistoryDownloadElementShell.prototype) 580 ) { 581 return false; 582 } 583 // If this function returns true, other controllers won't get a chance to 584 // process the command even if isCommandEnabled returns false, so it's 585 // important to check if the list is focused here to handle common commands 586 // like copy and paste correctly. The clear downloads command, instead, is 587 // specific to the downloads list but can be invoked from the toolbar, so we 588 // can just return true unconditionally. 589 return ( 590 aCommand == "downloadsCmd_clearDownloads" || 591 document.activeElement == this._richlistbox 592 ); 593 }, 594 595 // nsIController 596 isCommandEnabled(aCommand) { 597 switch (aCommand) { 598 case "cmd_copy": 599 return Array.prototype.some.call( 600 this._richlistbox.selectedItems, 601 element => { 602 const { source } = element._shell.download; 603 return !!(source?.originalUrl || source?.url); 604 } 605 ); 606 case "downloadsCmd_openReferrer": 607 case "downloadShowMenuItem": 608 return this._richlistbox.selectedItems.length == 1; 609 case "cmd_selectAll": 610 return true; 611 case "cmd_paste": 612 // We check later whether content is valid for pasting, or ignore it. 613 return Services.clipboard.hasDataMatchingFlavors( 614 CLIPBOARD_URL_FLAVORS, 615 Ci.nsIClipboard.kGlobalClipboard 616 ); 617 case "downloadsCmd_clearDownloads": 618 return this.canClearDownloads(this._richlistbox); 619 default: 620 return Array.prototype.every.call( 621 this._richlistbox.selectedItems, 622 element => element._shell.isCommandEnabled(aCommand) 623 ); 624 } 625 }, 626 627 _copySelectedDownloadsToClipboard() { 628 let urls = Array.from(this._richlistbox.selectedItems, element => { 629 const { source } = element._shell.download; 630 return source?.originalUrl || source?.url; 631 }).filter(Boolean); 632 633 Cc["@mozilla.org/widget/clipboardhelper;1"] 634 .getService(Ci.nsIClipboardHelper) 635 .copyString(urls.join("\n")); 636 }, 637 638 _getURLFromClipboardData() { 639 let trans = Cc["@mozilla.org/widget/transferable;1"].createInstance( 640 Ci.nsITransferable 641 ); 642 trans.init(null); 643 644 CLIPBOARD_URL_FLAVORS.forEach(trans.addDataFlavor); 645 646 Services.clipboard.getData(trans, Services.clipboard.kGlobalClipboard); 647 648 // Getting the data or creating the nsIURI might fail. 649 try { 650 let data = {}; 651 trans.getAnyTransferData({}, data); 652 let [url, name] = data.value 653 .QueryInterface(Ci.nsISupportsString) 654 .data.split("\n"); 655 if (url) { 656 return [NetUtil.newURI(url).spec, name]; 657 } 658 } catch (ex) {} 659 660 return ["", ""]; 661 }, 662 663 // nsIController 664 doCommand(aCommand) { 665 // Commands may be invoked with keyboard shortcuts even if disabled. 666 if (!this.isCommandEnabled(aCommand)) { 667 return; 668 } 669 670 // If this command is not selection-specific, execute it. 671 if (aCommand in this) { 672 this[aCommand](); 673 return; 674 } 675 676 // Cloning the nodelist into an array to get a frozen list of selected items. 677 // Otherwise, the selectedItems nodelist is live and doCommand may alter the 678 // selection while we are trying to do one particular action, like removing 679 // items from history. 680 let selectedElements = [...this._richlistbox.selectedItems]; 681 for (let element of selectedElements) { 682 element._shell.doCommand(aCommand); 683 } 684 }, 685 686 // nsIController 687 onEvent() {}, 688 689 cmd_copy() { 690 this._copySelectedDownloadsToClipboard(); 691 }, 692 693 cmd_selectAll() { 694 if (!this.searchTerm) { 695 this._richlistbox.selectAll(); 696 return; 697 } 698 // If there is a filtering search term, some rows are hidden and should not 699 // be selected. 700 let oldSuppressOnSelect = this._richlistbox.suppressOnSelect; 701 this._richlistbox.suppressOnSelect = true; 702 this._richlistbox.clearSelection(); 703 var item = this._richlistbox.getItemAtIndex(0); 704 while (item) { 705 if (!item.hidden) { 706 this._richlistbox.addItemToSelection(item); 707 } 708 item = this._richlistbox.getNextItem(item, 1); 709 } 710 this._richlistbox.suppressOnSelect = oldSuppressOnSelect; 711 }, 712 713 cmd_paste() { 714 let [url, name] = this._getURLFromClipboardData(); 715 if (url) { 716 let browserWin = BrowserWindowTracker.getTopWindow(); 717 let initiatingDoc = browserWin ? browserWin.document : document; 718 DownloadURL(url, name, initiatingDoc); 719 } 720 }, 721 722 downloadsCmd_clearDownloads() { 723 this._downloadsData.removeFinished(); 724 if (this._place) { 725 PlacesUtils.history 726 .removeVisitsByFilter({ 727 transition: PlacesUtils.history.TRANSITIONS.DOWNLOAD, 728 }) 729 .catch(console.error); 730 } 731 // There may be no selection or focus change as a result 732 // of these change, and we want the command updated immediately. 733 goUpdateCommand("downloadsCmd_clearDownloads"); 734 }, 735 736 onContextMenu() { 737 let element = this._richlistbox.selectedItem; 738 if (!element || !element._shell) { 739 return false; 740 } 741 742 let contextMenu = document.getElementById("downloadsContextMenu"); 743 DownloadsViewUI.updateContextMenuForElement(contextMenu, element); 744 // Hide the copy location item if there is somehow no URL. We have to do 745 // this here instead of in DownloadsViewUI because DownloadsView doesn't 746 // allow selecting multiple downloads, so in that view the menuitem will be 747 // shown according to whether just the selected item has a source URL. 748 contextMenu.querySelector(".downloadCopyLocationMenuItem").hidden = 749 !Array.prototype.some.call( 750 this._richlistbox.selectedItems, 751 el => !!el._shell.download.source?.url 752 ); 753 754 let download = element._shell.download; 755 if (!download.stopped) { 756 // The hasPartialData property of a download may change at any time after 757 // it has started, so ensure we update the related command now. 758 goUpdateCommand("downloadsCmd_pauseResume"); 759 } 760 761 return true; 762 }, 763 764 onKeyPress(aEvent) { 765 let selectedElements = this._richlistbox.selectedItems; 766 if (aEvent.keyCode == KeyEvent.DOM_VK_RETURN) { 767 // In the content tree, opening bookmarks by pressing return is only 768 // supported when a single item is selected. To be consistent, do the 769 // same here. 770 if (selectedElements.length == 1) { 771 let element = selectedElements[0]; 772 if (element._shell) { 773 element._shell.doDefaultCommand(aEvent); 774 } 775 } 776 } else if (aEvent.charCode == " ".charCodeAt(0)) { 777 let atLeastOneDownloadToggled = false; 778 // Pause/Resume every selected download 779 for (let element of selectedElements) { 780 if (element._shell.isCommandEnabled("downloadsCmd_pauseResume")) { 781 element._shell.doCommand("downloadsCmd_pauseResume"); 782 atLeastOneDownloadToggled = true; 783 } 784 } 785 786 if (atLeastOneDownloadToggled) { 787 aEvent.preventDefault(); 788 } 789 } 790 }, 791 792 onDoubleClick(aEvent) { 793 if (aEvent.button != 0) { 794 return; 795 } 796 797 let selectedElements = this._richlistbox.selectedItems; 798 if (selectedElements.length != 1) { 799 return; 800 } 801 802 let element = selectedElements[0]; 803 if (element._shell) { 804 element._shell.doDefaultCommand(aEvent); 805 } 806 }, 807 808 onScroll() { 809 this._ensureVisibleElementsAreActive(true); 810 }, 811 812 onSelect() { 813 goUpdateDownloadCommands(); 814 815 let selectedElements = this._richlistbox.selectedItems; 816 for (let elt of selectedElements) { 817 if (elt._shell) { 818 elt._shell.onSelect(); 819 } 820 } 821 }, 822 823 onDragStart(aEvent) { 824 // TODO Bug 831358: Support d&d for multiple selection. 825 // For now, we just drag the first element. 826 let selectedItem = this._richlistbox.selectedItem; 827 if (!selectedItem) { 828 return; 829 } 830 831 let targetPath = selectedItem._shell.download.target.path; 832 if (!targetPath) { 833 return; 834 } 835 836 // We must check for existence synchronously because this is a DOM event. 837 let file = new FileUtils.File(targetPath); 838 if (!file.exists()) { 839 return; 840 } 841 842 let dt = aEvent.dataTransfer; 843 dt.mozSetDataAt("application/x-moz-file", file, 0); 844 let url = Services.io.newFileURI(file).spec; 845 dt.setData("text/uri-list", url); 846 dt.effectAllowed = "copyMove"; 847 dt.addElement(selectedItem); 848 }, 849 850 onDragOver(aEvent) { 851 let types = aEvent.dataTransfer.types; 852 if ( 853 types.includes("text/uri-list") || 854 types.includes("text/x-moz-url") || 855 types.includes("text/plain") 856 ) { 857 aEvent.preventDefault(); 858 } 859 }, 860 861 onDrop(aEvent) { 862 let dt = aEvent.dataTransfer; 863 // If dragged item is from our source, do not try to 864 // redownload already downloaded file. 865 if (dt.mozGetDataAt("application/x-moz-file", 0)) { 866 return; 867 } 868 869 let links = Services.droppedLinkHandler.dropLinks(aEvent); 870 if (!links.length) { 871 return; 872 } 873 aEvent.preventDefault(); 874 let browserWin = BrowserWindowTracker.getTopWindow(); 875 let initiatingDoc = browserWin ? browserWin.document : document; 876 for (let link of links) { 877 if (link.url.startsWith("about:")) { 878 continue; 879 } 880 DownloadURL(link.url, link.name, initiatingDoc); 881 } 882 }, 883 }; 884 Object.setPrototypeOf( 885 DownloadsPlacesView.prototype, 886 DownloadsViewUI.BaseView.prototype 887 ); 888 889 for (let methodName of ["load", "applyFilter", "selectNode", "selectItems"]) { 890 DownloadsPlacesView.prototype[methodName] = function () { 891 throw new Error( 892 "|" + methodName + "| is not implemented by the downloads view." 893 ); 894 }; 895 } 896 897 function goUpdateDownloadCommands() { 898 function updateCommandsForObject(object) { 899 for (let name in object) { 900 if (DownloadsViewUI.isCommandName(name)) { 901 goUpdateCommand(name); 902 } 903 } 904 } 905 updateCommandsForObject(DownloadsPlacesView.prototype); 906 updateCommandsForObject(HistoryDownloadElementShell.prototype); 907 } 908 909 document.addEventListener("DOMContentLoaded", function () { 910 let richListBox = document.getElementById("downloadsListBox"); 911 richListBox.addEventListener("scroll", function () { 912 return this._placesView.onScroll(); 913 }); 914 richListBox.addEventListener("keypress", function (event) { 915 return this._placesView.onKeyPress(event); 916 }); 917 richListBox.addEventListener("dblclick", function (event) { 918 return this._placesView.onDoubleClick(event); 919 }); 920 richListBox.addEventListener("contextmenu", function (event) { 921 return this._placesView.onContextMenu(event); 922 }); 923 richListBox.addEventListener("dragstart", function (event) { 924 this._placesView.onDragStart(event); 925 }); 926 let dropNode = richListBox; 927 // In about:downloads, also allow drops if the list is empty, by 928 // adding the listener to the document, as the richlistbox is 929 // hidden when it is empty. 930 if (document.documentElement.id == "contentAreaDownloadsView") { 931 dropNode = richListBox.parentNode; 932 } 933 dropNode.addEventListener("dragover", function (event) { 934 richListBox._placesView.onDragOver(event); 935 }); 936 dropNode.addEventListener("drop", function (event) { 937 richListBox._placesView.onDrop(event); 938 }); 939 richListBox.addEventListener("select", function () { 940 this._placesView.onSelect(); 941 }); 942 richListBox.addEventListener("focus", goUpdateDownloadCommands); 943 richListBox.addEventListener("blur", goUpdateDownloadCommands); 944 });