indicator.js (19334B)
1 /* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ 2 /* vim: set ts=2 et sw=2 tw=80: */ 3 /* This Source Code Form is subject to the terms of the Mozilla Public 4 * License, v. 2.0. If a copy of the MPL was not distributed with this file, 5 * You can obtain one at http://mozilla.org/MPL/2.0/. */ 6 7 /** 8 * Handles the indicator that displays the progress of ongoing downloads, which 9 * is also used as the anchor for the downloads panel. 10 * 11 * This module includes the following constructors and global objects: 12 * 13 * DownloadsButton 14 * Main entry point for the downloads indicator. Depending on how the toolbars 15 * have been customized, this object determines if we should show a fully 16 * functional indicator, a placeholder used during customization and in the 17 * customization palette, or a neutral view as a temporary anchor for the 18 * downloads panel. 19 * 20 * DownloadsIndicatorView 21 * Builds and updates the actual downloads status widget, responding to changes 22 * in the global status data, or provides a neutral view if the indicator is 23 * removed from the toolbars and only used as a temporary anchor. In addition, 24 * handles the user interaction events raised by the widget. 25 */ 26 27 "use strict"; 28 29 // DownloadsButton 30 31 /** 32 * Main entry point for the downloads indicator. Depending on how the toolbars 33 * have been customized, this object determines if we should show a fully 34 * functional indicator, a placeholder used during customization and in the 35 * customization palette, or a neutral view as a temporary anchor for the 36 * downloads panel. 37 */ 38 const DownloadsButton = { 39 /** 40 * Returns a reference to the downloads button position placeholder, or null 41 * if not available because it has been removed from the toolbars. 42 */ 43 get _placeholder() { 44 return document.getElementById("downloads-button"); 45 }, 46 47 /** 48 * Indicates whether toolbar customization is in progress. 49 */ 50 _customizing: false, 51 52 /** 53 * This function is called asynchronously just after window initialization. 54 * 55 * NOTE: This function should limit the input/output it performs to improve 56 * startup time. 57 */ 58 initializeIndicator() { 59 DownloadsIndicatorView.ensureInitialized(); 60 }, 61 62 /** 63 * Determines the position where the indicator should appear, and moves its 64 * associated element to the new position. 65 * 66 * @return Anchor element, or null if the indicator is not visible. 67 */ 68 _getAnchorInternal() { 69 let indicator = DownloadsIndicatorView.indicator; 70 if (!indicator) { 71 // Exit now if the button is not in the document. 72 return null; 73 } 74 75 indicator.open = this._anchorRequested; 76 77 let widget = CustomizableUI.getWidget("downloads-button"); 78 // Determine if the indicator is located on an invisible toolbar. 79 if ( 80 !isElementVisible(indicator.parentNode) && 81 widget.areaType == CustomizableUI.TYPE_TOOLBAR 82 ) { 83 return null; 84 } 85 86 return DownloadsIndicatorView.indicatorAnchor; 87 }, 88 89 /** 90 * Indicates whether we should try and show the indicator temporarily as an 91 * anchor for the panel, even if the indicator would be hidden by default. 92 */ 93 _anchorRequested: false, 94 95 /** 96 * Ensures that there is an anchor available for the panel. 97 * 98 * @return Anchor element where the panel should be anchored, or null if an 99 * anchor is not available (for example because both the tab bar and 100 * the navigation bar are hidden). 101 */ 102 getAnchor() { 103 // Do not allow anchoring the panel to the element while customizing. 104 if (this._customizing) { 105 return null; 106 } 107 108 this._anchorRequested = true; 109 return this._getAnchorInternal(); 110 }, 111 112 /** 113 * Allows the temporary anchor to be hidden. 114 */ 115 releaseAnchor() { 116 this._anchorRequested = false; 117 this._getAnchorInternal(); 118 }, 119 120 /** 121 * Unhide the button. Generally, this only needs to use the placeholder. 122 * However, when starting customize mode, if the button is in the palette, 123 * we need to unhide it before customize mode is entered, otherwise it 124 * gets ignored by customize mode. To do this, we pass true for 125 * `includePalette`. We don't always look in the palette because it's 126 * inefficient (compared to getElementById), shouldn't be necessary, and 127 * if _placeholder returned the node even if in the palette, other checks 128 * would break. 129 * 130 * @param includePalette whether to search the palette, too. Defaults to false. 131 */ 132 unhide(includePalette = false) { 133 let button = this._placeholder; 134 let wasHidden = false; 135 if (!button && includePalette) { 136 button = gNavToolbox.palette.querySelector("#downloads-button"); 137 } 138 if (button && button.hasAttribute("hidden")) { 139 button.removeAttribute("hidden"); 140 if (this._navBar.contains(button)) { 141 this._navBar.setAttribute("downloadsbuttonshown", "true"); 142 } 143 wasHidden = true; 144 } 145 return wasHidden; 146 }, 147 148 hide() { 149 let button = this._placeholder; 150 if (this.autoHideDownloadsButton && button && button.closest("toolbar")) { 151 DownloadsPanel.hidePanel(); 152 button.hidden = true; 153 this._navBar.removeAttribute("downloadsbuttonshown"); 154 } 155 }, 156 157 startAutoHide() { 158 if (DownloadsIndicatorView.hasDownloads) { 159 this.unhide(); 160 } else { 161 this.hide(); 162 } 163 }, 164 165 checkForAutoHide() { 166 let button = this._placeholder; 167 if ( 168 !this._customizing && 169 this.autoHideDownloadsButton && 170 button && 171 button.closest("toolbar") 172 ) { 173 this.startAutoHide(); 174 } else { 175 this.unhide(); 176 } 177 }, 178 179 // Callback from CustomizableUI when nodes get moved around. 180 // We use this to track whether our node has moved somewhere 181 // where we should (not) autohide it. 182 onWidgetAfterDOMChange(node) { 183 if (node == this._placeholder) { 184 this.checkForAutoHide(); 185 } 186 }, 187 188 /** 189 * This function is called when toolbar customization starts. 190 * 191 * During customization, we never show the actual download progress indication 192 * or the event notifications, but we show a neutral placeholder. The neutral 193 * placeholder is an ordinary button defined in the browser window that can be 194 * moved freely between the toolbars and the customization palette. 195 */ 196 onCustomizeStart(win) { 197 if (win == window) { 198 // Prevent the indicator from being displayed as a temporary anchor 199 // during customization, even if requested using the getAnchor method. 200 this._customizing = true; 201 this._anchorRequested = false; 202 this.unhide(true); 203 } 204 }, 205 206 onCustomizeEnd(win) { 207 if (win == window) { 208 this._customizing = false; 209 this.checkForAutoHide(); 210 DownloadsIndicatorView.afterCustomize(); 211 } 212 }, 213 214 init() { 215 XPCOMUtils.defineLazyPreferenceGetter( 216 this, 217 "autoHideDownloadsButton", 218 "browser.download.autohideButton", 219 true, 220 this.checkForAutoHide.bind(this) 221 ); 222 223 CustomizableUI.addListener(this); 224 this.checkForAutoHide(); 225 }, 226 227 uninit() { 228 CustomizableUI.removeListener(this); 229 }, 230 231 get _tabsToolbar() { 232 delete this._tabsToolbar; 233 return (this._tabsToolbar = document.getElementById("TabsToolbar")); 234 }, 235 236 get _navBar() { 237 delete this._navBar; 238 return (this._navBar = document.getElementById("nav-bar")); 239 }, 240 }; 241 242 Object.defineProperty(this, "DownloadsButton", { 243 value: DownloadsButton, 244 enumerable: true, 245 writable: false, 246 }); 247 248 // DownloadsIndicatorView 249 250 /** 251 * Builds and updates the actual downloads status widget, responding to changes 252 * in the global status data, or provides a neutral view if the indicator is 253 * removed from the toolbars and only used as a temporary anchor. In addition, 254 * handles the user interaction events raised by the widget. 255 */ 256 const DownloadsIndicatorView = { 257 /** 258 * True when the view is connected with the underlying downloads data. 259 */ 260 _initialized: false, 261 262 /** 263 * True when the user interface elements required to display the indicator 264 * have finished loading in the browser window, and can be referenced. 265 */ 266 _operational: false, 267 268 /** 269 * Prepares the downloads indicator to be displayed. 270 */ 271 ensureInitialized() { 272 if (this._initialized) { 273 return; 274 } 275 this._initialized = true; 276 277 window.addEventListener("unload", this); 278 window.addEventListener("visibilitychange", this); 279 DownloadsCommon.getIndicatorData(window).addView(this); 280 }, 281 282 /** 283 * Frees the internal resources related to the indicator. 284 */ 285 ensureTerminated() { 286 if (!this._initialized) { 287 return; 288 } 289 this._initialized = false; 290 291 window.removeEventListener("unload", this); 292 window.removeEventListener("visibilitychange", this); 293 DownloadsCommon.getIndicatorData(window).removeView(this); 294 295 // Reset the view properties, so that a neutral indicator is displayed if we 296 // are visible only temporarily as an anchor. 297 this.percentComplete = 0; 298 this.attention = DownloadsCommon.ATTENTION_NONE; 299 }, 300 301 /** 302 * Ensures that the user interface elements required to display the indicator 303 * are loaded. 304 */ 305 _ensureOperational() { 306 if (this._operational) { 307 return; 308 } 309 310 // If we don't have a _placeholder, there's no chance that everything 311 // will load correctly: bail (and don't set _operational to true!) 312 if (!DownloadsButton._placeholder) { 313 return; 314 } 315 316 this._operational = true; 317 318 // If the view is initialized, we need to update the elements now that 319 // they are finally available in the document. 320 if (this._initialized) { 321 DownloadsCommon.getIndicatorData(window).refreshView(this); 322 } 323 }, 324 325 // Direct control functions 326 327 /** 328 * Set to the type ("start" or "finish") when display of a notification is in-progress 329 */ 330 _currentNotificationType: null, 331 332 /** 333 * Set to the type ("start" or "finish") when a notification arrives while we 334 * are waiting for the timeout of the previous notification 335 */ 336 _nextNotificationType: null, 337 338 /** 339 * Check if the panel containing aNode is open. 340 * 341 * @param aNode 342 * the node whose panel we're interested in. 343 */ 344 _isAncestorPanelOpen(aNode) { 345 while (aNode && aNode.localName != "panel") { 346 aNode = aNode.parentNode; 347 } 348 return aNode && aNode.state == "open"; 349 }, 350 351 /** 352 * Display or enqueue a visual notification of a relevant event, like a new download. 353 * 354 * @param aType 355 * Set to "start" for new downloads, "finish" for completed downloads. 356 */ 357 showEventNotification(aType) { 358 if (!this._initialized) { 359 return; 360 } 361 362 // enqueue this notification while the current one is being displayed 363 if (this._currentNotificationType) { 364 // only queue up the notification if it is different to the current one 365 if (this._currentNotificationType != aType) { 366 this._nextNotificationType = aType; 367 } 368 } else { 369 this._showNotification(aType); 370 } 371 }, 372 373 /** 374 * If the status indicator is visible in its assigned position, shows for a 375 * brief time a visual notification of a relevant event, like a new download. 376 * 377 * @param aType 378 * Set to "start" for new downloads, "finish" for completed downloads. 379 */ 380 _showNotification(aType) { 381 let anchor = DownloadsButton._placeholder; 382 if (!anchor || !isElementVisible(anchor.parentNode)) { 383 // Our container isn't visible, so can't show the animation: 384 return; 385 } 386 387 if (anchor.ownerGlobal.matchMedia("(prefers-reduced-motion)").matches) { 388 // User has prefers-reduced-motion enabled, so we shouldn't show the animation. 389 return; 390 } 391 392 anchor.setAttribute("notification", aType); 393 anchor.setAttribute("animate", ""); 394 395 // are we animating from an initially-hidden state? 396 anchor.toggleAttribute("washidden", !!this._wasHidden); 397 delete this._wasHidden; 398 399 this._currentNotificationType = aType; 400 401 const onNotificationAnimEnd = event => { 402 if ( 403 event.animationName !== "downloadsButtonNotification" && 404 event.animationName !== "downloadsButtonFinishedNotification" 405 ) { 406 return; 407 } 408 anchor.removeEventListener("animationend", onNotificationAnimEnd); 409 410 requestAnimationFrame(() => { 411 anchor.removeAttribute("notification"); 412 anchor.removeAttribute("animate"); 413 414 requestAnimationFrame(() => { 415 let nextType = this._nextNotificationType; 416 this._currentNotificationType = null; 417 this._nextNotificationType = null; 418 if (nextType && isElementVisible(anchor.parentNode)) { 419 this._showNotification(nextType); 420 } 421 }); 422 }); 423 }; 424 anchor.addEventListener("animationend", onNotificationAnimEnd); 425 }, 426 427 // Callback functions from DownloadsIndicatorData 428 429 /** 430 * Indicates whether the indicator should be shown because there are some 431 * downloads to be displayed. 432 */ 433 set hasDownloads(aValue) { 434 if (this._hasDownloads != aValue || (!this._operational && aValue)) { 435 this._hasDownloads = aValue; 436 437 // If there is at least one download, ensure that the view elements are 438 // operational 439 if (aValue) { 440 this._wasHidden = DownloadsButton.unhide(); 441 this._ensureOperational(); 442 } else { 443 DownloadsButton.checkForAutoHide(); 444 } 445 } 446 }, 447 get hasDownloads() { 448 return this._hasDownloads; 449 }, 450 _hasDownloads: false, 451 452 /** 453 * Progress indication to display, from 0 to 100, or -1 if unknown. 454 * Progress is not visible if the current progress is unknown. 455 */ 456 set percentComplete(aValue) { 457 if (!this._operational) { 458 return; 459 } 460 aValue = Math.min(100, aValue); 461 if (this._percentComplete !== aValue) { 462 // Initial progress may fire before the start event gets to us. 463 // To avoid flashing, trip the start event first. 464 if (this._percentComplete < 0 && aValue >= 0) { 465 this.showEventNotification("start"); 466 } 467 this._percentComplete = aValue; 468 this._refreshAttention(); 469 this._maybeScheduleProgressUpdate(); 470 } 471 }, 472 473 _maybeScheduleProgressUpdate() { 474 if ( 475 this.indicator && 476 !this._progressRaf && 477 document.visibilityState == "visible" 478 ) { 479 this._progressRaf = requestAnimationFrame(() => { 480 // indeterminate downloads (unknown content-length) will show up as aValue = 0 481 if (this._percentComplete >= 0) { 482 if (!this.indicator.hasAttribute("progress")) { 483 this.indicator.setAttribute("progress", "true"); 484 } 485 // For arrow type only: Set the % complete on the pie-chart. 486 // We use a minimum of 10% to ensure something is always visible 487 this._progressIcon.style.setProperty( 488 "--download-progress-pcent", 489 `${Math.max(10, this._percentComplete)}%` 490 ); 491 } else { 492 this.indicator.removeAttribute("progress"); 493 this._progressIcon.style.setProperty( 494 "--download-progress-pcent", 495 "0%" 496 ); 497 } 498 this._progressRaf = null; 499 }); 500 } 501 }, 502 _percentComplete: -1, 503 504 /** 505 * Set when the indicator should draw user attention to itself. 506 */ 507 set attention(aValue) { 508 if (!this._operational) { 509 return; 510 } 511 if (this._attention != aValue) { 512 this._attention = aValue; 513 this._refreshAttention(); 514 } 515 }, 516 517 _refreshAttention() { 518 // Check if the downloads button is in the menu panel, to determine which 519 // button needs to get a badge. 520 let widgetGroup = CustomizableUI.getWidget("downloads-button"); 521 let inMenu = widgetGroup.areaType == CustomizableUI.TYPE_PANEL; 522 523 // For arrow-Styled indicator, suppress success attention if we have 524 // progress in toolbar 525 let suppressAttention = 526 !inMenu && 527 this._attention == DownloadsCommon.ATTENTION_SUCCESS && 528 this._percentComplete >= 0; 529 530 if ( 531 suppressAttention || 532 this._attention == DownloadsCommon.ATTENTION_NONE 533 ) { 534 this.indicator.removeAttribute("attention"); 535 } else { 536 this.indicator.setAttribute("attention", this._attention); 537 } 538 }, 539 _attention: DownloadsCommon.ATTENTION_NONE, 540 541 // User interface event functions 542 handleEvent(aEvent) { 543 switch (aEvent.type) { 544 case "unload": 545 this.ensureTerminated(); 546 break; 547 548 case "visibilitychange": 549 this._maybeScheduleProgressUpdate(); 550 break; 551 } 552 }, 553 554 onCommand(aEvent) { 555 if ( 556 // On Mac, ctrl-click will send a context menu event from the widget, so 557 // we don't want to bring up the panel when ctrl key is pressed. 558 (aEvent.type == "mousedown" && 559 (aEvent.button != 0 || 560 (AppConstants.platform == "macosx" && aEvent.ctrlKey))) || 561 (aEvent.type == "keypress" && aEvent.key != " " && aEvent.key != "Enter") 562 ) { 563 return; 564 } 565 566 DownloadsPanel.showPanel( 567 /* openedManually */ true, 568 aEvent.type.startsWith("key") 569 ); 570 aEvent.stopPropagation(); 571 }, 572 573 onDragOver(aEvent) { 574 ToolbarDropHandler.onDragOver(aEvent); 575 }, 576 577 onDrop(aEvent) { 578 let dt = aEvent.dataTransfer; 579 // If dragged item is from our source, do not try to 580 // redownload already downloaded file. 581 if (dt.mozGetDataAt("application/x-moz-file", 0)) { 582 return; 583 } 584 585 let links = Services.droppedLinkHandler.dropLinks(aEvent); 586 if (!links.length) { 587 return; 588 } 589 let sourceDoc = dt.mozSourceNode 590 ? dt.mozSourceNode.ownerDocument 591 : document; 592 let handled = false; 593 for (let link of links) { 594 if (link.url.startsWith("about:")) { 595 continue; 596 } 597 saveURL( 598 link.url, 599 null, 600 link.name, 601 null, 602 true, 603 true, 604 null, 605 null, 606 sourceDoc 607 ); 608 handled = true; 609 } 610 if (handled) { 611 aEvent.preventDefault(); 612 } 613 }, 614 615 _indicator: null, 616 __progressIcon: null, 617 618 /** 619 * Returns a reference to the main indicator element, or null if the element 620 * is not present in the browser window yet. 621 */ 622 get indicator() { 623 if (!this._indicator) { 624 this._indicator = document.getElementById("downloads-button"); 625 } 626 627 return this._indicator; 628 }, 629 630 get indicatorAnchor() { 631 let widgetGroup = CustomizableUI.getWidget("downloads-button"); 632 if (widgetGroup.areaType == CustomizableUI.TYPE_PANEL) { 633 let overflowIcon = widgetGroup.forWindow(window).anchor; 634 return overflowIcon.icon; 635 } 636 637 return this.indicator.badgeStack; 638 }, 639 640 get _progressIcon() { 641 return ( 642 this.__progressIcon || 643 (this.__progressIcon = document.getElementById( 644 "downloads-indicator-progress-inner" 645 )) 646 ); 647 }, 648 649 _onCustomizedAway() { 650 this._indicator = null; 651 this.__progressIcon = null; 652 }, 653 654 afterCustomize() { 655 // If the cached indicator is not the one currently in the document, 656 // invalidate our references 657 if (this._indicator != document.getElementById("downloads-button")) { 658 this._onCustomizedAway(); 659 this._operational = false; 660 this.ensureTerminated(); 661 this.ensureInitialized(); 662 } 663 }, 664 }; 665 666 Object.defineProperty(this, "DownloadsIndicatorView", { 667 value: DownloadsIndicatorView, 668 enumerable: true, 669 writable: false, 670 });