diff options
Diffstat (limited to '')
-rw-r--r-- | browser/components/downloads/content/indicator.js | 670 |
1 files changed, 670 insertions, 0 deletions
diff --git a/browser/components/downloads/content/indicator.js b/browser/components/downloads/content/indicator.js new file mode 100644 index 0000000000..d0c4dc4163 --- /dev/null +++ b/browser/components/downloads/content/indicator.js @@ -0,0 +1,670 @@ +/* -*- 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, +}); |