/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ /* vim: set ts=2 et sw=2 tw=80: */ /* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this file, * You can obtain one at http://mozilla.org/MPL/2.0/. */ /* eslint-env mozilla/browser-window */ /** * Handles the indicator that displays the progress of ongoing downloads, which * is also used as the anchor for the downloads panel. * * This module includes the following constructors and global objects: * * DownloadsButton * Main entry point for the downloads indicator. Depending on how the toolbars * have been customized, this object determines if we should show a fully * functional indicator, a placeholder used during customization and in the * customization palette, or a neutral view as a temporary anchor for the * downloads panel. * * DownloadsIndicatorView * Builds and updates the actual downloads status widget, responding to changes * in the global status data, or provides a neutral view if the indicator is * removed from the toolbars and only used as a temporary anchor. In addition, * handles the user interaction events raised by the widget. */ "use strict"; // DownloadsButton /** * Main entry point for the downloads indicator. Depending on how the toolbars * have been customized, this object determines if we should show a fully * functional indicator, a placeholder used during customization and in the * customization palette, or a neutral view as a temporary anchor for the * downloads panel. */ const DownloadsButton = { /** * Returns a reference to the downloads button position placeholder, or null * if not available because it has been removed from the toolbars. */ get _placeholder() { return document.getElementById("downloads-button"); }, /** * Indicates whether toolbar customization is in progress. */ _customizing: false, /** * This function is called asynchronously just after window initialization. * * NOTE: This function should limit the input/output it performs to improve * startup time. */ initializeIndicator() { DownloadsIndicatorView.ensureInitialized(); }, /** * Determines the position where the indicator should appear, and moves its * associated element to the new position. * * @return Anchor element, or null if the indicator is not visible. */ _getAnchorInternal() { let indicator = DownloadsIndicatorView.indicator; if (!indicator) { // Exit now if the button is not in the document. return null; } indicator.open = this._anchorRequested; let widget = CustomizableUI.getWidget("downloads-button"); // Determine if the indicator is located on an invisible toolbar. if ( !isElementVisible(indicator.parentNode) && widget.areaType == CustomizableUI.TYPE_TOOLBAR ) { return null; } return DownloadsIndicatorView.indicatorAnchor; }, /** * Indicates whether we should try and show the indicator temporarily as an * anchor for the panel, even if the indicator would be hidden by default. */ _anchorRequested: false, /** * Ensures that there is an anchor available for the panel. * * @return Anchor element where the panel should be anchored, or null if an * anchor is not available (for example because both the tab bar and * the navigation bar are hidden). */ getAnchor() { // Do not allow anchoring the panel to the element while customizing. if (this._customizing) { return null; } this._anchorRequested = true; return this._getAnchorInternal(); }, /** * Allows the temporary anchor to be hidden. */ releaseAnchor() { this._anchorRequested = false; this._getAnchorInternal(); }, /** * Unhide the button. Generally, this only needs to use the placeholder. * However, when starting customize mode, if the button is in the palette, * we need to unhide it before customize mode is entered, otherwise it * gets ignored by customize mode. To do this, we pass true for * `includePalette`. We don't always look in the palette because it's * inefficient (compared to getElementById), shouldn't be necessary, and * if _placeholder returned the node even if in the palette, other checks * would break. * * @param includePalette whether to search the palette, too. Defaults to false. */ unhide(includePalette = false) { let button = this._placeholder; let wasHidden = false; if (!button && includePalette) { button = gNavToolbox.palette.querySelector("#downloads-button"); } if (button && button.hasAttribute("hidden")) { button.removeAttribute("hidden"); if (this._navBar.contains(button)) { this._navBar.setAttribute("downloadsbuttonshown", "true"); } wasHidden = true; } return wasHidden; }, hide() { let button = this._placeholder; if (this.autoHideDownloadsButton && button && button.closest("toolbar")) { DownloadsPanel.hidePanel(); button.hidden = true; this._navBar.removeAttribute("downloadsbuttonshown"); } }, startAutoHide() { if (DownloadsIndicatorView.hasDownloads) { this.unhide(); } else { this.hide(); } }, checkForAutoHide() { let button = this._placeholder; if ( !this._customizing && this.autoHideDownloadsButton && button && button.closest("toolbar") ) { this.startAutoHide(); } else { this.unhide(); } }, // Callback from CustomizableUI when nodes get moved around. // We use this to track whether our node has moved somewhere // where we should (not) autohide it. onWidgetAfterDOMChange(node) { if (node == this._placeholder) { this.checkForAutoHide(); } }, /** * This function is called when toolbar customization starts. * * During customization, we never show the actual download progress indication * or the event notifications, but we show a neutral placeholder. The neutral * placeholder is an ordinary button defined in the browser window that can be * moved freely between the toolbars and the customization palette. */ onCustomizeStart(win) { if (win == window) { // Prevent the indicator from being displayed as a temporary anchor // during customization, even if requested using the getAnchor method. this._customizing = true; this._anchorRequested = false; this.unhide(true); } }, onCustomizeEnd(win) { if (win == window) { this._customizing = false; this.checkForAutoHide(); DownloadsIndicatorView.afterCustomize(); } }, init() { XPCOMUtils.defineLazyPreferenceGetter( this, "autoHideDownloadsButton", "browser.download.autohideButton", true, this.checkForAutoHide.bind(this) ); CustomizableUI.addListener(this); this.checkForAutoHide(); }, uninit() { CustomizableUI.removeListener(this); }, get _tabsToolbar() { delete this._tabsToolbar; return (this._tabsToolbar = document.getElementById("TabsToolbar")); }, get _navBar() { delete this._navBar; return (this._navBar = document.getElementById("nav-bar")); }, }; Object.defineProperty(this, "DownloadsButton", { value: DownloadsButton, enumerable: true, writable: false, }); // DownloadsIndicatorView /** * Builds and updates the actual downloads status widget, responding to changes * in the global status data, or provides a neutral view if the indicator is * removed from the toolbars and only used as a temporary anchor. In addition, * handles the user interaction events raised by the widget. */ const DownloadsIndicatorView = { /** * True when the view is connected with the underlying downloads data. */ _initialized: false, /** * True when the user interface elements required to display the indicator * have finished loading in the browser window, and can be referenced. */ _operational: false, /** * Prepares the downloads indicator to be displayed. */ ensureInitialized() { if (this._initialized) { return; } this._initialized = true; window.addEventListener("unload", this); window.addEventListener("visibilitychange", this); DownloadsCommon.getIndicatorData(window).addView(this); }, /** * Frees the internal resources related to the indicator. */ ensureTerminated() { if (!this._initialized) { return; } this._initialized = false; window.removeEventListener("unload", this); window.removeEventListener("visibilitychange", this); DownloadsCommon.getIndicatorData(window).removeView(this); // Reset the view properties, so that a neutral indicator is displayed if we // are visible only temporarily as an anchor. this.percentComplete = 0; this.attention = DownloadsCommon.ATTENTION_NONE; }, /** * Ensures that the user interface elements required to display the indicator * are loaded. */ _ensureOperational() { if (this._operational) { return; } // If we don't have a _placeholder, there's no chance that everything // will load correctly: bail (and don't set _operational to true!) if (!DownloadsButton._placeholder) { return; } this._operational = true; // If the view is initialized, we need to update the elements now that // they are finally available in the document. if (this._initialized) { DownloadsCommon.getIndicatorData(window).refreshView(this); } }, // Direct control functions /** * Set to the type ("start" or "finish") when display of a notification is in-progress */ _currentNotificationType: null, /** * Set to the type ("start" or "finish") when a notification arrives while we * are waiting for the timeout of the previous notification */ _nextNotificationType: null, /** * Check if the panel containing aNode is open. * @param aNode * the node whose panel we're interested in. */ _isAncestorPanelOpen(aNode) { while (aNode && aNode.localName != "panel") { aNode = aNode.parentNode; } return aNode && aNode.state == "open"; }, /** * Display or enqueue a visual notification of a relevant event, like a new download. * * @param aType * Set to "start" for new downloads, "finish" for completed downloads. */ showEventNotification(aType) { if (!this._initialized) { return; } // enqueue this notification while the current one is being displayed if (this._currentNotificationType) { // only queue up the notification if it is different to the current one if (this._currentNotificationType != aType) { this._nextNotificationType = aType; } } else { this._showNotification(aType); } }, /** * If the status indicator is visible in its assigned position, shows for a * brief time a visual notification of a relevant event, like a new download. * * @param aType * Set to "start" for new downloads, "finish" for completed downloads. */ _showNotification(aType) { let anchor = DownloadsButton._placeholder; if (!anchor || !isElementVisible(anchor.parentNode)) { // Our container isn't visible, so can't show the animation: return; } if (anchor.ownerGlobal.matchMedia("(prefers-reduced-motion)").matches) { // User has prefers-reduced-motion enabled, so we shouldn't show the animation. return; } anchor.setAttribute("notification", aType); anchor.setAttribute("animate", ""); // are we animating from an initially-hidden state? anchor.toggleAttribute("washidden", !!this._wasHidden); delete this._wasHidden; this._currentNotificationType = aType; const onNotificationAnimEnd = event => { if ( event.animationName !== "downloadsButtonNotification" && event.animationName !== "downloadsButtonFinishedNotification" ) { return; } anchor.removeEventListener("animationend", onNotificationAnimEnd); requestAnimationFrame(() => { anchor.removeAttribute("notification"); anchor.removeAttribute("animate"); requestAnimationFrame(() => { let nextType = this._nextNotificationType; this._currentNotificationType = null; this._nextNotificationType = null; if (nextType && isElementVisible(anchor.parentNode)) { this._showNotification(nextType); } }); }); }; anchor.addEventListener("animationend", onNotificationAnimEnd); }, // Callback functions from DownloadsIndicatorData /** * Indicates whether the indicator should be shown because there are some * downloads to be displayed. */ set hasDownloads(aValue) { if (this._hasDownloads != aValue || (!this._operational && aValue)) { this._hasDownloads = aValue; // If there is at least one download, ensure that the view elements are // operational if (aValue) { this._wasHidden = DownloadsButton.unhide(); this._ensureOperational(); } else { DownloadsButton.checkForAutoHide(); } } }, get hasDownloads() { return this._hasDownloads; }, _hasDownloads: false, /** * Progress indication to display, from 0 to 100, or -1 if unknown. * Progress is not visible if the current progress is unknown. */ set percentComplete(aValue) { if (!this._operational) { return; } aValue = Math.min(100, aValue); if (this._percentComplete !== aValue) { // Initial progress may fire before the start event gets to us. // To avoid flashing, trip the start event first. if (this._percentComplete < 0 && aValue >= 0) { this.showEventNotification("start"); } this._percentComplete = aValue; this._refreshAttention(); this._maybeScheduleProgressUpdate(); } }, _maybeScheduleProgressUpdate() { if ( this.indicator && !this._progressRaf && document.visibilityState == "visible" ) { this._progressRaf = requestAnimationFrame(() => { // indeterminate downloads (unknown content-length) will show up as aValue = 0 if (this._percentComplete >= 0) { if (!this.indicator.hasAttribute("progress")) { this.indicator.setAttribute("progress", "true"); } // For arrow type only: Set the % complete on the pie-chart. // We use a minimum of 10% to ensure something is always visible this._progressIcon.style.setProperty( "--download-progress-pcent", `${Math.max(10, this._percentComplete)}%` ); } else { this.indicator.removeAttribute("progress"); this._progressIcon.style.setProperty( "--download-progress-pcent", "0%" ); } this._progressRaf = null; }); } }, _percentComplete: -1, /** * Set when the indicator should draw user attention to itself. */ set attention(aValue) { if (!this._operational) { return; } if (this._attention != aValue) { this._attention = aValue; this._refreshAttention(); } }, _refreshAttention() { // Check if the downloads button is in the menu panel, to determine which // button needs to get a badge. let widgetGroup = CustomizableUI.getWidget("downloads-button"); let inMenu = widgetGroup.areaType == CustomizableUI.TYPE_PANEL; // For arrow-Styled indicator, suppress success attention if we have // progress in toolbar let suppressAttention = !inMenu && this._attention == DownloadsCommon.ATTENTION_SUCCESS && this._percentComplete >= 0; if ( suppressAttention || this._attention == DownloadsCommon.ATTENTION_NONE ) { this.indicator.removeAttribute("attention"); } else { this.indicator.setAttribute("attention", this._attention); } }, _attention: DownloadsCommon.ATTENTION_NONE, // User interface event functions handleEvent(aEvent) { switch (aEvent.type) { case "unload": this.ensureTerminated(); break; case "visibilitychange": this._maybeScheduleProgressUpdate(); break; } }, onCommand(aEvent) { if ( // On Mac, ctrl-click will send a context menu event from the widget, so // we don't want to bring up the panel when ctrl key is pressed. (aEvent.type == "mousedown" && (aEvent.button != 0 || (AppConstants.platform == "macosx" && aEvent.ctrlKey))) || (aEvent.type == "keypress" && aEvent.key != " " && aEvent.key != "Enter") ) { return; } DownloadsPanel.showPanel( /* openedManually */ true, aEvent.type.startsWith("key") ); aEvent.stopPropagation(); }, onDragOver(aEvent) { browserDragAndDrop.dragOver(aEvent); }, onDrop(aEvent) { let dt = aEvent.dataTransfer; // If dragged item is from our source, do not try to // redownload already downloaded file. if (dt.mozGetDataAt("application/x-moz-file", 0)) { return; } let links = browserDragAndDrop.dropLinks(aEvent); if (!links.length) { return; } let sourceDoc = dt.mozSourceNode ? dt.mozSourceNode.ownerDocument : document; let handled = false; for (let link of links) { if (link.url.startsWith("about:")) { continue; } saveURL( link.url, null, link.name, null, true, true, null, null, sourceDoc ); handled = true; } if (handled) { aEvent.preventDefault(); } }, _indicator: null, __progressIcon: null, /** * Returns a reference to the main indicator element, or null if the element * is not present in the browser window yet. */ get indicator() { if (!this._indicator) { this._indicator = document.getElementById("downloads-button"); } return this._indicator; }, get indicatorAnchor() { let widgetGroup = CustomizableUI.getWidget("downloads-button"); if (widgetGroup.areaType == CustomizableUI.TYPE_PANEL) { let overflowIcon = widgetGroup.forWindow(window).anchor; return overflowIcon.icon; } return this.indicator.badgeStack; }, get _progressIcon() { return ( this.__progressIcon || (this.__progressIcon = document.getElementById( "downloads-indicator-progress-inner" )) ); }, _onCustomizedAway() { this._indicator = null; this.__progressIcon = null; }, afterCustomize() { // If the cached indicator is not the one currently in the document, // invalidate our references if (this._indicator != document.getElementById("downloads-button")) { this._onCustomizedAway(); this._operational = false; this.ensureTerminated(); this.ensureInitialized(); } }, }; Object.defineProperty(this, "DownloadsIndicatorView", { value: DownloadsIndicatorView, enumerable: true, writable: false, });