diff options
Diffstat (limited to '')
11 files changed, 2185 insertions, 0 deletions
diff --git a/devtools/client/framework/components/ChromeDebugToolbar.css b/devtools/client/framework/components/ChromeDebugToolbar.css new file mode 100644 index 0000000000..4b74d47e05 --- /dev/null +++ b/devtools/client/framework/components/ChromeDebugToolbar.css @@ -0,0 +1,60 @@ +/* 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/. */ + +.chrome-debug-toolbar { + display: flex; + padding: 0.5em 1em; + font-size: 12px; + line-height: 1.5; + background-color: var(--theme-body-alternate-emphasized-background); + font-family: system-ui, -apple-system, sans-serif; + border-block-end: 1px solid var(--theme-toolbar-separator); +} + +.chrome-debug-toolbar section > h3 { + margin: 0; + font-weight: normal; +} + +.chrome-debug-toolbar__modes { + display: flex; + align-items: baseline; + gap: 0.5em 1em; + flex-wrap: wrap; +} + +.chrome-debug-toolbar__modes label { + border: 1px solid var(--theme-toolbar-separator); + border-radius: 4px; + padding: 4px 8px; +} + +.chrome-debug-toolbar__modes label.selected { + border-color: var(--theme-toolbar-selected-color); +} + +.chrome-debug-toolbar__modes label:where(:hover, :focus-within) { + background-color: var(--blue-50-a30); +} + +.chrome-debug-toolbar__modes label input { + margin: 0; + margin-inline-end: 4px; +} + +.mode__sublabel { + color: var(--theme-comment); + margin-inline-start: 4px; +} + +@media (prefers-contrast) { + .chrome-debug-toolbar { + background-color: Window; + color: WindowText; + } + + .mode_sublabel { + color: GrayText; + } +} diff --git a/devtools/client/framework/components/ChromeDebugToolbar.js b/devtools/client/framework/components/ChromeDebugToolbar.js new file mode 100644 index 0000000000..b126cf78fd --- /dev/null +++ b/devtools/client/framework/components/ChromeDebugToolbar.js @@ -0,0 +1,123 @@ +/* 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/. */ +"use strict"; + +const { + PureComponent, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +const FluentReact = require("resource://devtools/client/shared/vendor/fluent-react.js"); +const Localized = createFactory(FluentReact.Localized); + +const MODE_PREF = "devtools.browsertoolbox.scope"; + +const MODE_VALUES = { + PARENT_PROCESS: "parent-process", + EVERYTHING: "everything", +}; + +const MODE_DATA = { + [MODE_VALUES.PARENT_PROCESS]: { + containerL10nId: "toolbox-mode-parent-process-container", + labelL10nId: "toolbox-mode-parent-process-label", + subLabelL10nId: "toolbox-mode-parent-process-sub-label", + }, + [MODE_VALUES.EVERYTHING]: { + containerL10nId: "toolbox-mode-everything-container", + labelL10nId: "toolbox-mode-everything-label", + subLabelL10nId: "toolbox-mode-everything-sub-label", + }, +}; + +/** + * Toolbar displayed on top of the regular toolbar in the Browser Toolbox and Browser Console, + * displaying chrome-debugging-specific options. + */ +class ChromeDebugToolbar extends PureComponent { + static get propTypes() { + return { + isBrowserConsole: PropTypes.bool, + }; + } + + constructor(props) { + super(props); + + this.state = { + mode: Services.prefs.getCharPref(MODE_PREF), + }; + + this.onModePrefChanged = this.onModePrefChanged.bind(this); + Services.prefs.addObserver(MODE_PREF, this.onModePrefChanged); + } + + componentWillUnmount() { + Services.prefs.removeObserver(MODE_PREF, this.onModePrefChanged); + } + + onModePrefChanged() { + this.setState({ + mode: Services.prefs.getCharPref(MODE_PREF), + }); + } + + renderModeItem(value) { + const { containerL10nId, labelL10nId, subLabelL10nId } = MODE_DATA[value]; + + const checked = this.state.mode == value; + return Localized( + { + id: containerL10nId, + attrs: { title: true }, + }, + dom.label( + { + className: checked ? "selected" : null, + }, + dom.input({ + type: `radio`, + name: `chrome-debug-mode`, + value, + checked: checked || null, + onChange: () => { + Services.prefs.setCharPref(MODE_PREF, value); + }, + }), + Localized({ id: labelL10nId }, dom.span({ className: "mode__label" })), + Localized( + { id: subLabelL10nId }, + dom.span({ className: "mode__sublabel" }) + ) + ) + ); + } + + render() { + return dom.header( + { + className: "chrome-debug-toolbar", + }, + dom.section( + { + className: "chrome-debug-toolbar__modes", + }, + Localized( + { + id: this.props.isBrowserConsole + ? "toolbox-mode-browser-console-label" + : "toolbox-mode-browser-toolbox-label", + }, + dom.h3({}) + ), + this.renderModeItem(MODE_VALUES.PARENT_PROCESS), + this.renderModeItem(MODE_VALUES.EVERYTHING) + ) + ); + } +} + +module.exports = ChromeDebugToolbar; diff --git a/devtools/client/framework/components/DebugTargetErrorPage.css b/devtools/client/framework/components/DebugTargetErrorPage.css new file mode 100644 index 0000000000..ffac30cece --- /dev/null +++ b/devtools/client/framework/components/DebugTargetErrorPage.css @@ -0,0 +1,21 @@ +/* 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/. */ + +.error-page { + --base-unit: 4px; /* from photon */ + + padding: calc(var(--base-unit) * 4); + font-size: 15px; /* from photon */ + min-height: 100vh; +} + +.error-page__title { + margin: 0; + font-size: 36px; /* from photon */ + font-weight: 200; /* from photon */ +} + +.error-page__details { + font-family: monospace; +} diff --git a/devtools/client/framework/components/DebugTargetErrorPage.js b/devtools/client/framework/components/DebugTargetErrorPage.js new file mode 100644 index 0000000000..9790b9cd7f --- /dev/null +++ b/devtools/client/framework/components/DebugTargetErrorPage.js @@ -0,0 +1,49 @@ +/* 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/. */ + +"use strict"; + +const { + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +/** + * This component is displayed when the about:devtools-toolbox fails to load + * properly due to wrong parameters or debug targets that don't exist. + */ +class DebugTargetErrorPage extends PureComponent { + static get propTypes() { + return { + errorMessage: PropTypes.string.isRequired, + L10N: PropTypes.object.isRequired, + }; + } + + render() { + const { errorMessage, L10N } = this.props; + + return dom.article( + { + className: "error-page qa-error-page", + }, + dom.h1( + { + className: "error-page__title", + }, + L10N.getStr("toolbox.debugTargetErrorPage.title") + ), + dom.p({}, L10N.getStr("toolbox.debugTargetErrorPage.description")), + dom.output( + { + className: "error-page__details", + }, + errorMessage + ) + ); + } +} + +module.exports = DebugTargetErrorPage; diff --git a/devtools/client/framework/components/DebugTargetInfo.js b/devtools/client/framework/components/DebugTargetInfo.js new file mode 100644 index 0000000000..e3911e96c9 --- /dev/null +++ b/devtools/client/framework/components/DebugTargetInfo.js @@ -0,0 +1,401 @@ +/* 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/. */ +"use strict"; + +const { + PureComponent, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const { + CONNECTION_TYPES, +} = require("resource://devtools/client/shared/remote-debugging/constants.js"); +const DESCRIPTOR_TYPES = require("resource://devtools/client/fronts/descriptors/descriptor-types.js"); +const FluentReact = require("resource://devtools/client/shared/vendor/fluent-react.js"); +const Localized = createFactory(FluentReact.Localized); + +/** + * This is header that should be displayed on top of the toolbox when using + * about:devtools-toolbox. + */ +class DebugTargetInfo extends PureComponent { + static get propTypes() { + return { + alwaysOnTop: PropTypes.boolean.isRequired, + focusedState: PropTypes.boolean, + toggleAlwaysOnTop: PropTypes.func.isRequired, + debugTargetData: PropTypes.shape({ + connectionType: PropTypes.oneOf(Object.values(CONNECTION_TYPES)) + .isRequired, + runtimeInfo: PropTypes.shape({ + deviceName: PropTypes.string, + icon: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + version: PropTypes.string.isRequired, + }).isRequired, + descriptorType: PropTypes.oneOf(Object.values(DESCRIPTOR_TYPES)) + .isRequired, + }).isRequired, + L10N: PropTypes.object.isRequired, + toolbox: PropTypes.object.isRequired, + }; + } + + constructor(props) { + super(props); + + this.state = { urlValue: props.toolbox.target.url }; + + this.onChange = this.onChange.bind(this); + this.onFocus = this.onFocus.bind(this); + this.onSubmit = this.onSubmit.bind(this); + } + + componentDidMount() { + this.updateTitle(); + } + + updateTitle() { + const { L10N, debugTargetData, toolbox } = this.props; + const title = toolbox.target.name; + const descriptorTypeStr = L10N.getStr( + this.getAssetsForDebugDescriptorType().l10nId + ); + + const { connectionType } = debugTargetData; + if (connectionType === CONNECTION_TYPES.THIS_FIREFOX) { + toolbox.doc.title = L10N.getFormatStr( + "toolbox.debugTargetInfo.tabTitleLocal", + descriptorTypeStr, + title + ); + } else { + const connectionTypeStr = L10N.getStr( + this.getAssetsForConnectionType().l10nId + ); + toolbox.doc.title = L10N.getFormatStr( + "toolbox.debugTargetInfo.tabTitleRemote", + connectionTypeStr, + descriptorTypeStr, + title + ); + } + } + + getRuntimeText() { + const { debugTargetData, L10N } = this.props; + const { name, version } = debugTargetData.runtimeInfo; + const { connectionType } = debugTargetData; + const brandShorterName = L10N.getStr("brandShorterName"); + + return connectionType === CONNECTION_TYPES.THIS_FIREFOX + ? L10N.getFormatStr( + "toolbox.debugTargetInfo.runtimeLabel.thisRuntime", + brandShorterName, + version + ) + : L10N.getFormatStr( + "toolbox.debugTargetInfo.runtimeLabel", + name, + version + ); + } + + getAssetsForConnectionType() { + const { connectionType } = this.props.debugTargetData; + + switch (connectionType) { + case CONNECTION_TYPES.USB: + return { + image: "chrome://devtools/skin/images/aboutdebugging-usb-icon.svg", + l10nId: "toolbox.debugTargetInfo.connection.usb", + }; + case CONNECTION_TYPES.NETWORK: + return { + image: "chrome://devtools/skin/images/aboutdebugging-globe-icon.svg", + l10nId: "toolbox.debugTargetInfo.connection.network", + }; + default: + return {}; + } + } + + getAssetsForDebugDescriptorType() { + const { descriptorType } = this.props.debugTargetData; + + // TODO: https://bugzilla.mozilla.org/show_bug.cgi?id=1520723 + // Show actual favicon (currently toolbox.target.activeTab.favicon + // is unpopulated) + const favicon = "chrome://devtools/skin/images/globe.svg"; + + switch (descriptorType) { + case DESCRIPTOR_TYPES.EXTENSION: + return { + image: "chrome://devtools/skin/images/debugging-addons.svg", + l10nId: "toolbox.debugTargetInfo.targetType.extension", + }; + case DESCRIPTOR_TYPES.PROCESS: + return { + image: "chrome://devtools/skin/images/settings.svg", + l10nId: "toolbox.debugTargetInfo.targetType.process", + }; + case DESCRIPTOR_TYPES.TAB: + return { + image: favicon, + l10nId: "toolbox.debugTargetInfo.targetType.tab", + }; + case DESCRIPTOR_TYPES.WORKER: + return { + image: "chrome://devtools/skin/images/debugging-workers.svg", + l10nId: "toolbox.debugTargetInfo.targetType.worker", + }; + default: + return {}; + } + } + + onChange({ target }) { + this.setState({ urlValue: target.value }); + } + + onFocus({ target }) { + target.select(); + } + + onSubmit(event) { + event.preventDefault(); + let url = this.state.urlValue; + + if (!url || !url.length) { + return; + } + + try { + // Get the URL from the fixup service: + const flags = Services.uriFixup.FIXUP_FLAG_FIX_SCHEME_TYPOS; + const uriInfo = Services.uriFixup.getFixupURIInfo(url, flags); + url = uriInfo.fixedURI.spec; + } catch (ex) { + // The getFixupURIInfo service will throw an error if a malformed URI is + // produced from the input. + console.error(ex); + } + + this.props.toolbox.target.navigateTo({ url }); + } + + shallRenderConnection() { + const { connectionType } = this.props.debugTargetData; + const renderableTypes = [CONNECTION_TYPES.USB, CONNECTION_TYPES.NETWORK]; + + return renderableTypes.includes(connectionType); + } + + renderConnection() { + const { connectionType } = this.props.debugTargetData; + const { image, l10nId } = this.getAssetsForConnectionType(); + + return dom.span( + { + className: "iconized-label qa-connection-info", + }, + dom.img({ src: image, alt: `${connectionType} icon` }), + this.props.L10N.getStr(l10nId) + ); + } + + renderRuntime() { + if ( + !this.props.debugTargetData.runtimeInfo || + (this.props.debugTargetData.connectionType === + CONNECTION_TYPES.THIS_FIREFOX && + this.props.debugTargetData.descriptorType === + DESCRIPTOR_TYPES.EXTENSION) + ) { + // Skip the runtime render if no runtimeInfo is available. + // Runtime info is retrieved from the remote-client-manager, which might not be + // setup if about:devtools-toolbox was not opened from about:debugging. + // + // Also skip the runtime if we are debugging firefox itself, mainly to save some space. + return null; + } + + const { icon, deviceName } = this.props.debugTargetData.runtimeInfo; + + return dom.span( + { + className: "iconized-label qa-runtime-info", + }, + dom.img({ src: icon, className: "channel-icon qa-runtime-icon" }), + dom.b({ className: "devtools-ellipsis-text" }, this.getRuntimeText()), + dom.span({ className: "devtools-ellipsis-text" }, deviceName) + ); + } + + renderTargetTitle() { + const title = this.props.toolbox.target.name; + + const { image, l10nId } = this.getAssetsForDebugDescriptorType(); + + return dom.span( + { + className: "iconized-label debug-target-title", + }, + dom.img({ src: image, alt: this.props.L10N.getStr(l10nId) }), + title + ? dom.b({ className: "devtools-ellipsis-text qa-target-title" }, title) + : null + ); + } + + renderTargetURI() { + const url = this.props.toolbox.target.url; + const { descriptorType } = this.props.debugTargetData; + const isURLEditable = descriptorType === DESCRIPTOR_TYPES.TAB; + + return dom.span( + { + key: url, + className: "debug-target-url", + }, + isURLEditable + ? this.renderTargetInput(url) + : dom.span( + { className: "debug-target-url-readonly devtools-ellipsis-text" }, + url + ) + ); + } + + renderTargetInput(url) { + return dom.form( + { + className: "debug-target-url-form", + onSubmit: this.onSubmit, + }, + dom.input({ + className: "devtools-textinput debug-target-url-input", + onChange: this.onChange, + onFocus: this.onFocus, + defaultValue: url, + }) + ); + } + + renderAlwaysOnTopButton() { + // This is only displayed for local web extension debugging + const { descriptorType, connectionType } = this.props.debugTargetData; + const isLocalWebExtension = + descriptorType === DESCRIPTOR_TYPES.EXTENSION && + connectionType === CONNECTION_TYPES.THIS_FIREFOX; + if (!isLocalWebExtension) { + return []; + } + + const checked = this.props.alwaysOnTop; + const toolboxFocused = this.props.focusedState; + return [ + Localized( + { + id: checked + ? "toolbox-always-on-top-enabled2" + : "toolbox-always-on-top-disabled2", + attrs: { title: true }, + }, + dom.button({ + className: + `toolbox-always-on-top` + + (checked ? " checked" : "") + + (toolboxFocused ? " toolbox-is-focused" : ""), + onClick: this.props.toggleAlwaysOnTop, + }) + ), + ]; + } + + renderNavigationButton(detail) { + const { L10N } = this.props; + + return dom.button( + { + className: `iconized-label navigation-button ${detail.className}`, + onClick: detail.onClick, + title: L10N.getStr(detail.l10nId), + }, + dom.img({ + src: detail.icon, + alt: L10N.getStr(detail.l10nId), + }) + ); + } + + renderNavigation() { + const { debugTargetData } = this.props; + const { descriptorType } = debugTargetData; + + if ( + descriptorType !== DESCRIPTOR_TYPES.TAB && + descriptorType !== DESCRIPTOR_TYPES.EXTENSION + ) { + return null; + } + + const items = []; + + // There is little value in exposing back/forward for WebExtensions + if ( + this.props.toolbox.target.getTrait("navigation") && + descriptorType === DESCRIPTOR_TYPES.TAB + ) { + items.push( + this.renderNavigationButton({ + className: "qa-back-button", + icon: "chrome://browser/skin/back.svg", + l10nId: "toolbox.debugTargetInfo.back", + onClick: () => this.props.toolbox.target.goBack(), + }), + this.renderNavigationButton({ + className: "qa-forward-button", + icon: "chrome://browser/skin/forward.svg", + l10nId: "toolbox.debugTargetInfo.forward", + onClick: () => this.props.toolbox.target.goForward(), + }) + ); + } + + items.push( + this.renderNavigationButton({ + className: "qa-reload-button", + icon: "chrome://global/skin/icons/reload.svg", + l10nId: "toolbox.debugTargetInfo.reload", + onClick: () => + this.props.toolbox.commands.targetCommand.reloadTopLevelTarget(), + }) + ); + + return dom.div( + { + className: "debug-target-navigation", + }, + ...items + ); + } + + render() { + return dom.header( + { + className: "debug-target-info qa-debug-target-info", + }, + this.shallRenderConnection() ? this.renderConnection() : null, + this.renderRuntime(), + this.renderTargetTitle(), + this.renderNavigation(), + this.renderTargetURI(), + ...this.renderAlwaysOnTopButton() + ); + } +} + +module.exports = DebugTargetInfo; diff --git a/devtools/client/framework/components/MeatballMenu.js b/devtools/client/framework/components/MeatballMenu.js new file mode 100644 index 0000000000..fc694171c8 --- /dev/null +++ b/devtools/client/framework/components/MeatballMenu.js @@ -0,0 +1,299 @@ +/* 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/. */ +"use strict"; + +const { + PureComponent, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const { hr } = dom; + +loader.lazyGetter(this, "MenuItem", function () { + return createFactory( + require("resource://devtools/client/shared/components/menu/MenuItem.js") + ); +}); +loader.lazyGetter(this, "MenuList", function () { + return createFactory( + require("resource://devtools/client/shared/components/menu/MenuList.js") + ); +}); + +loader.lazyRequireGetter( + this, + "openDocLink", + "resource://devtools/client/shared/link.js", + true +); +loader.lazyRequireGetter( + this, + "assert", + "resource://devtools/shared/DevToolsUtils.js", + true +); + +const openDevToolsDocsLink = () => { + openDocLink("https://firefox-source-docs.mozilla.org/devtools-user/"); +}; + +const openCommunityLink = () => { + openDocLink( + "https://discourse.mozilla.org/c/devtools?utm_source=devtools&utm_medium=tabbar-menu" + ); +}; + +class MeatballMenu extends PureComponent { + static get propTypes() { + return { + // The id of the currently selected tool, e.g. "inspector" + currentToolId: PropTypes.string, + + // List of possible docking options. + hostTypes: PropTypes.arrayOf( + PropTypes.shape({ + position: PropTypes.string.isRequired, + switchHost: PropTypes.func.isRequired, + }) + ), + + // Current docking type. Typically one of the position values in + // |hostTypes| but this is not always the case (e.g. for "browsertoolbox"). + currentHostType: PropTypes.string, + + // Is the split console currently visible? + isSplitConsoleActive: PropTypes.bool, + + // Are we disabling the behavior where pop-ups are automatically closed + // when clicking outside them? + // + // This is a tri-state value that may be true/false or undefined where + // undefined means that the option is not relevant in this context + // (i.e. we're not in a browser toolbox). + disableAutohide: PropTypes.bool, + + // Apply a pseudo-locale to the Firefox UI. This is only available in the browser + // toolbox. This value can be undefined, "accented", "bidi", "none". + pseudoLocale: PropTypes.string, + + // Function to turn the options panel on / off. + toggleOptions: PropTypes.func.isRequired, + + // Function to turn the split console on / off. + toggleSplitConsole: PropTypes.func, + + // Function to turn the disable pop-up autohide behavior on / off. + toggleNoAutohide: PropTypes.func, + + // Manage the pseudo-localization for the Firefox UI. + // https://firefox-source-docs.mozilla.org/l10n/fluent/tutorial.html#manually-testing-ui-with-pseudolocalization + disablePseudoLocale: PropTypes.func, + enableAccentedPseudoLocale: PropTypes.func, + enableBidiPseudoLocale: PropTypes.func, + + // Bug 1709191 - The help shortcut key is localized without Fluent, and still needs + // to be migrated. This is the only remaining use of the legacy L10N object. + // Everything else should prefer the Fluent API. + L10N: PropTypes.object.isRequired, + + // Callback function that will be invoked any time the component contents + // update in such a way that its bounding box might change. + onResize: PropTypes.func, + }; + } + + componentDidUpdate(prevProps) { + if (!this.props.onResize) { + return; + } + + // We are only expecting the following kinds of dynamic changes when a popup + // is showing: + // + // - The "Disable pop-up autohide" menu item being added after the Browser + // Toolbox is connected. + // - The pseudo locale options being added after the Browser Toolbox is connected. + // - The split console label changing between "Show Split Console" and "Hide + // Split Console". + // - The "Show/Hide Split Console" entry being added removed or removed. + // + // The latter two cases are only likely to be noticed when "Disable pop-up + // autohide" is active, but for completeness we handle them here. + const didChange = + typeof this.props.disableAutohide !== typeof prevProps.disableAutohide || + this.props.pseudoLocale !== prevProps.pseudoLocale || + this.props.currentToolId !== prevProps.currentToolId || + this.props.isSplitConsoleActive !== prevProps.isSplitConsoleActive; + + if (didChange) { + this.props.onResize(); + } + } + + render() { + const items = []; + + // Dock options + for (const hostType of this.props.hostTypes) { + // This is more verbose than it needs to be but lets us easily search for + // l10n entities. + let l10nID; + switch (hostType.position) { + case "window": + l10nID = "toolbox-meatball-menu-dock-separate-window-label"; + break; + + case "bottom": + l10nID = "toolbox-meatball-menu-dock-bottom-label"; + break; + + case "left": + l10nID = "toolbox-meatball-menu-dock-left-label"; + break; + + case "right": + l10nID = "toolbox-meatball-menu-dock-right-label"; + break; + + default: + assert(false, `Unexpected hostType.position: ${hostType.position}`); + break; + } + + items.push( + MenuItem({ + id: `toolbox-meatball-menu-dock-${hostType.position}`, + key: `dock-${hostType.position}`, + l10nID, + onClick: hostType.switchHost, + checked: hostType.position === this.props.currentHostType, + className: "iconic", + }) + ); + } + + if (items.length) { + items.push(hr({ key: "dock-separator" })); + } + + // Split console + if (this.props.currentToolId !== "webconsole") { + const l10nID = this.props.isSplitConsoleActive + ? "toolbox-meatball-menu-hideconsole-label" + : "toolbox-meatball-menu-splitconsole-label"; + items.push( + MenuItem({ + id: "toolbox-meatball-menu-splitconsole", + key: "splitconsole", + l10nID, + accelerator: "Esc", + onClick: this.props.toggleSplitConsole, + className: "iconic", + }) + ); + } + + // Settings + items.push( + MenuItem({ + id: "toolbox-meatball-menu-settings", + key: "settings", + l10nID: "toolbox-meatball-menu-settings-label", + // Bug 1709191 - The help key is localized without Fluent, and still needs to + // be migrated. + accelerator: this.props.L10N.getStr("toolbox.help.key"), + onClick: this.props.toggleOptions, + className: "iconic", + }) + ); + + if ( + typeof this.props.disableAutohide !== "undefined" || + typeof this.props.pseudoLocale !== "undefined" + ) { + items.push(hr({ key: "docs-separator-1" })); + } + + // Disable pop-up autohide + // + // If |disableAutohide| is undefined, it means this feature is not available + // in this context. + if (typeof this.props.disableAutohide !== "undefined") { + items.push( + MenuItem({ + id: "toolbox-meatball-menu-noautohide", + key: "noautohide", + l10nID: "toolbox-meatball-menu-noautohide-label", + type: "checkbox", + checked: this.props.disableAutohide, + onClick: this.props.toggleNoAutohide, + className: "iconic", + }) + ); + } + + // Pseudo-locales. + if (typeof this.props.pseudoLocale !== "undefined") { + const { + pseudoLocale, + enableAccentedPseudoLocale, + enableBidiPseudoLocale, + disablePseudoLocale, + } = this.props; + items.push( + MenuItem({ + id: "toolbox-meatball-menu-pseudo-locale-accented", + key: "pseudo-locale-accented", + l10nID: "toolbox-meatball-menu-pseudo-locale-accented", + type: "checkbox", + checked: pseudoLocale === "accented", + onClick: + pseudoLocale === "accented" + ? disablePseudoLocale + : enableAccentedPseudoLocale, + className: "iconic", + }), + MenuItem({ + id: "toolbox-meatball-menu-pseudo-locale-bidi", + key: "pseudo-locale-bidi", + l10nID: "toolbox-meatball-menu-pseudo-locale-bidi", + type: "checkbox", + checked: pseudoLocale === "bidi", + onClick: + pseudoLocale === "bidi" + ? disablePseudoLocale + : enableBidiPseudoLocale, + className: "iconic", + }) + ); + } + + items.push(hr({ key: "docs-separator-2" })); + + // Getting started + items.push( + MenuItem({ + id: "toolbox-meatball-menu-documentation", + key: "documentation", + l10nID: "toolbox-meatball-menu-documentation-label", + onClick: openDevToolsDocsLink, + }) + ); + + // Give feedback + items.push( + MenuItem({ + id: "toolbox-meatball-menu-community", + key: "community", + l10nID: "toolbox-meatball-menu-community-label", + onClick: openCommunityLink, + }) + ); + + return MenuList({ id: "toolbox-meatball-menu" }, items); + } +} + +module.exports = MeatballMenu; diff --git a/devtools/client/framework/components/ToolboxController.js b/devtools/client/framework/components/ToolboxController.js new file mode 100644 index 0000000000..17d0c8a278 --- /dev/null +++ b/devtools/client/framework/components/ToolboxController.js @@ -0,0 +1,231 @@ +/* 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/. */ +"use strict"; + +const { + Component, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const ToolboxToolbar = createFactory( + require("resource://devtools/client/framework/components/ToolboxToolbar.js") +); +const ELEMENT_PICKER_ID = "command-button-pick"; + +/** + * This component serves as a state controller for the toolbox React component. It's a + * thin layer for translating events and state of the outside world into the React update + * cycle. This solution was used to keep the amount of code changes to a minimimum while + * adapting the existing codebase to start using React. + */ +class ToolboxController extends Component { + constructor(props, context) { + super(props, context); + + // See the ToolboxToolbar propTypes for documentation on each of these items in + // state, and for the definitions of the props that are expected to be passed in. + this.state = { + focusedButton: ELEMENT_PICKER_ID, + toolboxButtons: [], + visibleToolboxButtonCount: 0, + currentToolId: null, + highlightedTools: new Set(), + panelDefinitions: [], + hostTypes: [], + currentHostType: undefined, + areDockOptionsEnabled: true, + canCloseToolbox: true, + isSplitConsoleActive: false, + disableAutohide: undefined, + alwaysOnTop: undefined, + pseudoLocale: undefined, + canRender: false, + buttonIds: [], + checkedButtonsUpdated: () => { + this.forceUpdate(); + }, + }; + + this.setFocusedButton = this.setFocusedButton.bind(this); + this.setToolboxButtons = this.setToolboxButtons.bind(this); + this.setCurrentToolId = this.setCurrentToolId.bind(this); + this.highlightTool = this.highlightTool.bind(this); + this.unhighlightTool = this.unhighlightTool.bind(this); + this.setHostTypes = this.setHostTypes.bind(this); + this.setCurrentHostType = this.setCurrentHostType.bind(this); + this.setDockOptionsEnabled = this.setDockOptionsEnabled.bind(this); + this.setCanCloseToolbox = this.setCanCloseToolbox.bind(this); + this.setIsSplitConsoleActive = this.setIsSplitConsoleActive.bind(this); + this.setDisableAutohide = this.setDisableAutohide.bind(this); + this.setCanRender = this.setCanRender.bind(this); + this.setPanelDefinitions = this.setPanelDefinitions.bind(this); + this.updateButtonIds = this.updateButtonIds.bind(this); + this.updateFocusedButton = this.updateFocusedButton.bind(this); + this.setDebugTargetData = this.setDebugTargetData.bind(this); + } + + shouldComponentUpdate() { + return this.state.canRender; + } + + componentWillUnmount() { + this.state.toolboxButtons.forEach(button => { + button.off("updatechecked", this.state.checkedButtonsUpdated); + }); + } + + /** + * The button and tab ids must be known in order to be able to focus left and right + * using the arrow keys. + */ + updateButtonIds() { + const { toolboxButtons, panelDefinitions, canCloseToolbox } = this.state; + + // This is a little gnarly, but go through all of the state and extract the IDs. + this.setState({ + buttonIds: [ + ...toolboxButtons + .filter(btn => btn.isInStartContainer) + .map(({ id }) => id), + ...panelDefinitions.map(({ id }) => id), + ...toolboxButtons + .filter(btn => !btn.isInStartContainer) + .map(({ id }) => id), + canCloseToolbox ? "toolbox-close" : null, + ].filter(id => id), + }); + + this.updateFocusedButton(); + } + + updateFocusedButton() { + this.setFocusedButton(this.state.focusedButton); + } + + setFocusedButton(focusedButton) { + const { buttonIds } = this.state; + + focusedButton = + focusedButton && buttonIds.includes(focusedButton) + ? focusedButton + : buttonIds[0]; + if (this.state.focusedButton !== focusedButton) { + this.setState({ + focusedButton, + }); + } + } + + setCurrentToolId(currentToolId) { + this.setState({ currentToolId }, () => { + // Also set the currently focused button to this tool. + this.setFocusedButton(currentToolId); + }); + } + + setCanRender() { + this.setState({ canRender: true }, this.updateButtonIds); + } + + highlightTool(highlightedTool) { + const { highlightedTools } = this.state; + highlightedTools.add(highlightedTool); + this.setState({ highlightedTools }); + } + + unhighlightTool(id) { + const { highlightedTools } = this.state; + if (highlightedTools.has(id)) { + highlightedTools.delete(id); + this.setState({ highlightedTools }); + } + } + + setDockOptionsEnabled(areDockOptionsEnabled) { + this.setState({ areDockOptionsEnabled }); + } + + setHostTypes(hostTypes) { + this.setState({ hostTypes }); + } + + setCurrentHostType(currentHostType) { + this.setState({ currentHostType }); + } + + setCanCloseToolbox(canCloseToolbox) { + this.setState({ canCloseToolbox }, this.updateButtonIds); + } + + setIsSplitConsoleActive(isSplitConsoleActive) { + this.setState({ isSplitConsoleActive }); + } + + /** + * @param {bool | undefined} disableAutohide + */ + setDisableAutohide(disableAutohide) { + this.setState({ disableAutohide }); + } + + /** + * @param {bool | undefined} alwaysOnTop + */ + setAlwaysOnTop(alwaysOnTop) { + this.setState({ alwaysOnTop }); + } + + /** + * @param {bool} focusedState + */ + setFocusedState(focusedState) { + // We only care about the focused state when the toolbox is always on top + if (this.state.alwaysOnTop) { + this.setState({ focusedState }); + } + } + + /** + * @param {"bidi" | "accented" | "none" | undefined} pseudoLocale + */ + setPseudoLocale(pseudoLocale) { + this.setState({ pseudoLocale }); + } + + setPanelDefinitions(panelDefinitions) { + this.setState({ panelDefinitions }, this.updateButtonIds); + } + + get panelDefinitions() { + return this.state.panelDefinitions; + } + + setToolboxButtons(toolboxButtons) { + // Listen for updates of the checked attribute. + this.state.toolboxButtons.forEach(button => { + button.off("updatechecked", this.state.checkedButtonsUpdated); + }); + toolboxButtons.forEach(button => { + button.on("updatechecked", this.state.checkedButtonsUpdated); + }); + + const visibleToolboxButtonCount = toolboxButtons.filter( + button => button.isVisible + ).length; + + this.setState( + { toolboxButtons, visibleToolboxButtonCount }, + this.updateButtonIds + ); + } + + setDebugTargetData(data) { + this.setState({ debugTargetData: data }); + } + + render() { + return ToolboxToolbar(Object.assign({}, this.props, this.state)); + } +} + +module.exports = ToolboxController; diff --git a/devtools/client/framework/components/ToolboxTab.js b/devtools/client/framework/components/ToolboxTab.js new file mode 100644 index 0000000000..680b68e3e5 --- /dev/null +++ b/devtools/client/framework/components/ToolboxTab.js @@ -0,0 +1,106 @@ +/* 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/. */ +"use strict"; + +const { + Component, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const { img, button, span } = dom; + +class ToolboxTab extends Component { + // See toolbox-toolbar propTypes for details on the props used here. + static get propTypes() { + return { + currentToolId: PropTypes.string, + focusButton: PropTypes.func, + focusedButton: PropTypes.string, + highlightedTools: PropTypes.object.isRequired, + panelDefinition: PropTypes.object, + selectTool: PropTypes.func, + }; + } + + constructor(props) { + super(props); + this.renderIcon = this.renderIcon.bind(this); + } + + renderIcon(definition) { + const { icon } = definition; + if (!icon) { + return []; + } + return [ + img({ + alt: "", + src: icon, + }), + ]; + } + + render() { + const { + panelDefinition, + currentToolId, + highlightedTools, + selectTool, + focusedButton, + focusButton, + } = this.props; + const { id, extensionId, tooltip, label, iconOnly, badge } = + panelDefinition; + const isHighlighted = id === currentToolId; + + const className = [ + "devtools-tab", + currentToolId === id ? "selected" : "", + highlightedTools.has(id) ? "highlighted" : "", + iconOnly ? "devtools-tab-icon-only" : "", + ].join(" "); + + return button( + { + className, + id: `toolbox-tab-${id}`, + "data-id": id, + "data-extension-id": extensionId, + title: tooltip, + type: "button", + "aria-pressed": currentToolId === id ? "true" : "false", + tabIndex: focusedButton === id ? "0" : "-1", + onFocus: () => focusButton(id), + onMouseDown: () => selectTool(id, "tab_switch"), + onKeyDown: evt => { + if (evt.key === "Enter" || evt.key === " ") { + selectTool(id, "tab_switch"); + } + }, + }, + span({ + className: "devtools-tab-line", + }), + ...this.renderIcon(panelDefinition), + iconOnly + ? null + : span( + { + className: "devtools-tab-label", + }, + label, + badge && !isHighlighted + ? span( + { + className: "devtools-tab-badge", + }, + badge + ) + : null + ) + ); + } +} + +module.exports = ToolboxTab; diff --git a/devtools/client/framework/components/ToolboxTabs.js b/devtools/client/framework/components/ToolboxTabs.js new file mode 100644 index 0000000000..04b7d653a4 --- /dev/null +++ b/devtools/client/framework/components/ToolboxTabs.js @@ -0,0 +1,331 @@ +/* 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/. */ +"use strict"; + +const { + Component, + createFactory, + createRef, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const { + ToolboxTabsOrderManager, +} = require("resource://devtools/client/framework/toolbox-tabs-order-manager.js"); + +const { div } = dom; + +const ToolboxTab = createFactory( + require("resource://devtools/client/framework/components/ToolboxTab.js") +); + +loader.lazyGetter(this, "MenuButton", function () { + return createFactory( + require("resource://devtools/client/shared/components/menu/MenuButton.js") + ); +}); +loader.lazyGetter(this, "MenuItem", function () { + return createFactory( + require("resource://devtools/client/shared/components/menu/MenuItem.js") + ); +}); +loader.lazyGetter(this, "MenuList", function () { + return createFactory( + require("resource://devtools/client/shared/components/menu/MenuList.js") + ); +}); + +// 26px is chevron devtools button width.(i.e. tools-chevronmenu) +const CHEVRON_BUTTON_WIDTH = 26; + +class ToolboxTabs extends Component { + // See toolbox-toolbar propTypes for details on the props used here. + static get propTypes() { + return { + currentToolId: PropTypes.string, + focusButton: PropTypes.func, + focusedButton: PropTypes.string, + highlightedTools: PropTypes.object, + panelDefinitions: PropTypes.array, + selectTool: PropTypes.func, + toolbox: PropTypes.object, + visibleToolboxButtonCount: PropTypes.number.isRequired, + L10N: PropTypes.object, + onTabsOrderUpdated: PropTypes.func.isRequired, + }; + } + + constructor(props) { + super(props); + + this.state = { + // Array of overflowed tool id. + overflowedTabIds: [], + }; + + this.wrapperEl = createRef(); + + // Map with tool Id and its width size. This lifecycle is out of React's + // lifecycle. If a tool is registered, ToolboxTabs will add target tool id + // to this map. ToolboxTabs will never remove tool id from this cache. + this._cachedToolTabsWidthMap = new Map(); + + this._resizeTimerId = null; + this.resizeHandler = this.resizeHandler.bind(this); + + const { toolbox, onTabsOrderUpdated, panelDefinitions } = props; + this._tabsOrderManager = new ToolboxTabsOrderManager( + toolbox, + onTabsOrderUpdated, + panelDefinitions + ); + } + + componentDidMount() { + window.addEventListener("resize", this.resizeHandler); + this.updateCachedToolTabsWidthMap(); + this.updateOverflowedTabs(); + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillUpdate(nextProps, nextState) { + if (this.shouldUpdateToolboxTabs(this.props, nextProps)) { + // Force recalculate and render in this cycle if panel definition has + // changed or selected tool has changed. + nextState.overflowedTabIds = []; + } + } + + componentDidUpdate(prevProps, prevState) { + if (this.shouldUpdateToolboxTabs(prevProps, this.props)) { + this.updateCachedToolTabsWidthMap(); + this.updateOverflowedTabs(); + this._tabsOrderManager.setCurrentPanelDefinitions( + this.props.panelDefinitions + ); + } + } + + componentWillUnmount() { + window.removeEventListener("resize", this.resizeHandler); + window.cancelIdleCallback(this._resizeTimerId); + this._tabsOrderManager.destroy(); + } + + /** + * Check if two array of ids are the same or not. + */ + equalToolIdArray(prevPanels, nextPanels) { + if (prevPanels.length !== nextPanels.length) { + return false; + } + + // Check panel definitions even if both of array size is same. + // For example, the case of changing the tab's order. + return prevPanels.join("-") === nextPanels.join("-"); + } + + /** + * Return true if we should update the overflowed tabs. + */ + shouldUpdateToolboxTabs(prevProps, nextProps) { + if ( + prevProps.currentToolId !== nextProps.currentToolId || + prevProps.visibleToolboxButtonCount !== + nextProps.visibleToolboxButtonCount + ) { + return true; + } + + const prevPanels = prevProps.panelDefinitions.map(def => def.id); + const nextPanels = nextProps.panelDefinitions.map(def => def.id); + return !this.equalToolIdArray(prevPanels, nextPanels); + } + + /** + * Update the Map of tool id and tool tab width. + */ + updateCachedToolTabsWidthMap() { + const utils = window.windowUtils; + // Force a reflow before calling getBoundingWithoutFlushing on each tab. + this.wrapperEl.current.clientWidth; + + for (const tab of this.wrapperEl.current.querySelectorAll( + ".devtools-tab" + )) { + const tabId = tab.id.replace("toolbox-tab-", ""); + if (!this._cachedToolTabsWidthMap.has(tabId)) { + const rect = utils.getBoundsWithoutFlushing(tab); + this._cachedToolTabsWidthMap.set(tabId, rect.width); + } + } + } + + /** + * Update the overflowed tab array from currently displayed tool tab. + * If calculated result is the same as the current overflowed tab array, this + * function will not update state. + */ + updateOverflowedTabs() { + const toolboxWidth = parseInt( + getComputedStyle(this.wrapperEl.current).width, + 10 + ); + const { currentToolId } = this.props; + const enabledTabs = this.props.panelDefinitions.map(def => def.id); + let sumWidth = 0; + const visibleTabs = []; + + for (const id of enabledTabs) { + const width = this._cachedToolTabsWidthMap.get(id); + sumWidth += width; + if (sumWidth <= toolboxWidth) { + visibleTabs.push(id); + } else { + sumWidth = sumWidth - width + CHEVRON_BUTTON_WIDTH; + + // If toolbox can't display the Chevron, remove the last tool tab. + if (sumWidth > toolboxWidth) { + const removeTabId = visibleTabs.pop(); + sumWidth -= this._cachedToolTabsWidthMap.get(removeTabId); + } + break; + } + } + + // If the selected tab is in overflowed tabs, insert it into a visible + // toolbox. + if ( + !visibleTabs.includes(currentToolId) && + enabledTabs.includes(currentToolId) + ) { + const selectedToolWidth = this._cachedToolTabsWidthMap.get(currentToolId); + while ( + sumWidth + selectedToolWidth > toolboxWidth && + visibleTabs.length + ) { + const removingToolId = visibleTabs.pop(); + const removingToolWidth = + this._cachedToolTabsWidthMap.get(removingToolId); + sumWidth -= removingToolWidth; + } + + // If toolbox width is narrow, toolbox display only chevron menu. + // i.e. All tool tabs will overflow. + if (sumWidth + selectedToolWidth <= toolboxWidth) { + visibleTabs.push(currentToolId); + } + } + + const willOverflowTabs = enabledTabs.filter( + id => !visibleTabs.includes(id) + ); + if (!this.equalToolIdArray(this.state.overflowedTabIds, willOverflowTabs)) { + this.setState({ overflowedTabIds: willOverflowTabs }); + } + } + + resizeHandler(evt) { + window.cancelIdleCallback(this._resizeTimerId); + this._resizeTimerId = window.requestIdleCallback( + () => { + this.updateOverflowedTabs(); + }, + { timeout: 100 } + ); + } + + renderToolsChevronMenuList() { + const { panelDefinitions, selectTool } = this.props; + + const items = []; + for (const { id, label, icon } of panelDefinitions) { + if (this.state.overflowedTabIds.includes(id)) { + items.push( + MenuItem({ + key: id, + id: "tools-chevron-menupopup-" + id, + label, + type: "checkbox", + onClick: () => { + selectTool(id, "tab_switch"); + }, + icon, + }) + ); + } + } + + return MenuList({ id: "tools-chevron-menupopup" }, items); + } + + /** + * Render a button to access overflowed tools, displayed only when the toolbar + * presents an overflow. + */ + renderToolsChevronButton() { + const { toolbox } = this.props; + + return MenuButton( + { + id: "tools-chevron-menu-button", + menuId: "tools-chevron-menu-button-panel", + className: "devtools-tabbar-button tools-chevron-menu", + toolboxDoc: toolbox.doc, + }, + this.renderToolsChevronMenuList() + ); + } + + /** + * Render all of the tabs, based on the panel definitions and builds out + * a toolbox tab for each of them. Will render the chevron button if the + * container has an overflow. + */ + render() { + const { + currentToolId, + focusButton, + focusedButton, + highlightedTools, + panelDefinitions, + selectTool, + } = this.props; + + const tabs = panelDefinitions.map(panelDefinition => { + // Don't display overflowed tab. + if (!this.state.overflowedTabIds.includes(panelDefinition.id)) { + return ToolboxTab({ + key: panelDefinition.id, + currentToolId, + focusButton, + focusedButton, + highlightedTools, + panelDefinition, + selectTool, + }); + } + return null; + }); + + return div( + { + className: "toolbox-tabs-wrapper", + ref: this.wrapperEl, + }, + div( + { + className: "toolbox-tabs", + onMouseDown: e => this._tabsOrderManager.onMouseDown(e), + }, + tabs, + this.state.overflowedTabIds.length + ? this.renderToolsChevronButton() + : null + ) + ); + } +} + +module.exports = ToolboxTabs; diff --git a/devtools/client/framework/components/ToolboxToolbar.js b/devtools/client/framework/components/ToolboxToolbar.js new file mode 100644 index 0000000000..6f94d0282b --- /dev/null +++ b/devtools/client/framework/components/ToolboxToolbar.js @@ -0,0 +1,547 @@ +/* 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/. */ +"use strict"; + +const { + Component, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const { div, button } = dom; +const MenuButton = createFactory( + require("resource://devtools/client/shared/components/menu/MenuButton.js") +); +const ToolboxTabs = createFactory( + require("resource://devtools/client/framework/components/ToolboxTabs.js") +); +loader.lazyGetter(this, "MeatballMenu", function () { + return createFactory( + require("resource://devtools/client/framework/components/MeatballMenu.js") + ); +}); +loader.lazyGetter(this, "MenuItem", function () { + return createFactory( + require("resource://devtools/client/shared/components/menu/MenuItem.js") + ); +}); +loader.lazyGetter(this, "MenuList", function () { + return createFactory( + require("resource://devtools/client/shared/components/menu/MenuList.js") + ); +}); +loader.lazyGetter(this, "LocalizationProvider", function () { + return createFactory( + require("resource://devtools/client/shared/vendor/fluent-react.js") + .LocalizationProvider + ); +}); +loader.lazyGetter(this, "DebugTargetInfo", () => + createFactory( + require("resource://devtools/client/framework/components/DebugTargetInfo.js") + ) +); +loader.lazyGetter(this, "ChromeDebugToolbar", () => + createFactory( + require("resource://devtools/client/framework/components/ChromeDebugToolbar.js") + ) +); + +loader.lazyRequireGetter( + this, + "getUnicodeUrl", + "resource://devtools/client/shared/unicode-url.js", + true +); + +/** + * This is the overall component for the toolbox toolbar. It is designed to not know how + * the state is being managed, and attempts to be as pure as possible. The + * ToolboxController component controls the changing state, and passes in everything as + * props. + */ +class ToolboxToolbar extends Component { + static get propTypes() { + return { + // The currently focused item (for arrow keyboard navigation) + // This ID determines the tabindex being 0 or -1. + focusedButton: PropTypes.string, + // List of command button definitions. + toolboxButtons: PropTypes.array, + // The id of the currently selected tool, e.g. "inspector" + currentToolId: PropTypes.string, + // An optionally highlighted tools, e.g. "inspector" (used by ToolboxTabs + // component). + highlightedTools: PropTypes.instanceOf(Set), + // List of tool panel definitions (used by ToolboxTabs component). + panelDefinitions: PropTypes.array, + // List of possible docking options. + hostTypes: PropTypes.arrayOf( + PropTypes.shape({ + position: PropTypes.string.isRequired, + switchHost: PropTypes.func.isRequired, + }) + ), + // Current docking type. Typically one of the position values in + // |hostTypes| but this is not always the case (e.g. for "browsertoolbox"). + currentHostType: PropTypes.string, + // Are docking options enabled? They are not enabled in certain situations + // like when the toolbox is opened in a tab. + areDockOptionsEnabled: PropTypes.bool, + // Do we need to add UI for closing the toolbox? We don't when the + // toolbox is undocked, for example. + canCloseToolbox: PropTypes.bool, + // Is the split console currently visible? + isSplitConsoleActive: PropTypes.bool, + // Are we disabling the behavior where pop-ups are automatically closed + // when clicking outside them? + // + // This is a tri-state value that may be true/false or undefined where + // undefined means that the option is not relevant in this context + // (i.e. we're not in a browser toolbox). + disableAutohide: PropTypes.bool, + // Are we displaying the window always on top? + // + // This is a tri-state value that may be true/false or undefined where + // undefined means that the option is not relevant in this context + // (i.e. we're not in a local web extension toolbox). + alwaysOnTop: PropTypes.bool, + // Is the toolbox currently focused? + // + // This will only be defined when alwaysOnTop is true. + focusedState: PropTypes.bool, + // Function to turn the options panel on / off. + toggleOptions: PropTypes.func.isRequired, + // Function to turn the split console on / off. + toggleSplitConsole: PropTypes.func, + // Function to turn the disable pop-up autohide behavior on / off. + toggleNoAutohide: PropTypes.func, + // Function to turn the always on top behavior on / off. + toggleAlwaysOnTop: PropTypes.func, + // Function to completely close the toolbox. + closeToolbox: PropTypes.func, + // Keep a record of what button is focused. + focusButton: PropTypes.func, + // Hold off displaying the toolbar until enough information is ready for + // it to render nicely. + canRender: PropTypes.bool, + // Localization interface. + L10N: PropTypes.object.isRequired, + // The devtools toolbox + toolbox: PropTypes.object, + // Call back function to detect tabs order updated. + onTabsOrderUpdated: PropTypes.func.isRequired, + // Count of visible toolbox buttons which is used by ToolboxTabs component + // to recognize that the visibility of toolbox buttons were changed. + // Because in the component we cannot compare the visibility since the + // button definition instance in toolboxButtons will be unchanged. + visibleToolboxButtonCount: PropTypes.number, + // Data to show debug target info, if needed + debugTargetData: PropTypes.shape({ + runtimeInfo: PropTypes.object.isRequired, + descriptorType: PropTypes.string.isRequired, + }), + // The loaded Fluent localization bundles. + fluentBundles: PropTypes.array.isRequired, + }; + } + + constructor(props) { + super(props); + + this.hideMenu = this.hideMenu.bind(this); + this.createFrameList = this.createFrameList.bind(this); + this.highlightFrame = this.highlightFrame.bind(this); + } + + componentDidMount() { + this.props.toolbox.on("panel-changed", this.hideMenu); + } + + componentWillUnmount() { + this.props.toolbox.off("panel-changed", this.hideMenu); + } + + hideMenu() { + if (this.refs.meatballMenuButton) { + this.refs.meatballMenuButton.hideMenu(); + } + + if (this.refs.frameMenuButton) { + this.refs.frameMenuButton.hideMenu(); + } + } + + /** + * A little helper function to call renderToolboxButtons for buttons at the start + * of the toolbox. + */ + renderToolboxButtonsStart() { + return this.renderToolboxButtons(true); + } + + /** + * A little helper function to call renderToolboxButtons for buttons at the end + * of the toolbox. + */ + renderToolboxButtonsEnd() { + return this.renderToolboxButtons(false); + } + + /** + * Render all of the tabs, this takes in a list of toolbox button states. These are plain + * objects that have all of the relevant information needed to render the button. + * See Toolbox.prototype._createButtonState in devtools/client/framework/toolbox.js for + * documentation on this object. + * + * @param {String} focusedButton - The id of the focused button. + * @param {Array} toolboxButtons - Array of objects that define the command buttons. + * @param {Function} focusButton - Keep a record of the currently focused button. + * @param {boolean} isStart - Render either the starting buttons, or ending buttons. + */ + renderToolboxButtons(isStart) { + const { focusedButton, toolboxButtons, focusButton } = this.props; + const visibleButtons = toolboxButtons.filter(command => { + const { isVisible, isInStartContainer } = command; + return isVisible && (isStart ? isInStartContainer : !isInStartContainer); + }); + + if (visibleButtons.length === 0) { + return null; + } + + // The RDM button, if present, should always go last + const rdmIndex = visibleButtons.findIndex( + button => button.id === "command-button-responsive" + ); + if (rdmIndex !== -1 && rdmIndex !== visibleButtons.length - 1) { + const rdm = visibleButtons.splice(rdmIndex, 1)[0]; + visibleButtons.push(rdm); + } + + const renderedButtons = visibleButtons.map(command => { + const { + id, + description, + disabled, + onClick, + isChecked, + isToggle, + className: buttonClass, + onKeyDown, + } = command; + + // If button is frame button, create menu button in order to + // use the doorhanger menu. + if (id === "command-button-frames") { + return this.renderFrameButton(command); + } + + if (id === "command-button-errorcount") { + return this.renderErrorIcon(command); + } + + return button({ + id, + title: description, + disabled, + "aria-pressed": !isToggle ? null : isChecked, + className: `devtools-tabbar-button command-button ${ + buttonClass || "" + } ${isChecked ? "checked" : ""}`, + onClick: event => { + onClick(event); + focusButton(id); + }, + onFocus: () => focusButton(id), + tabIndex: id === focusedButton ? "0" : "-1", + onKeyDown: event => { + onKeyDown(event); + }, + }); + }); + + // Add the appropriate separator, if needed. + const children = renderedButtons; + if (renderedButtons.length) { + if (isStart) { + children.push(this.renderSeparator()); + // For the end group we add a separator *before* the RDM button if it + // exists, but only if it is not the only button. + } else if (rdmIndex !== -1 && renderedButtons.length > 1) { + children.splice(children.length - 1, 0, this.renderSeparator()); + } + } + + return div( + { id: `toolbox-buttons-${isStart ? "start" : "end"}` }, + ...children + ); + } + + renderFrameButton(command) { + const { id, isChecked, disabled, description } = command; + + const { toolbox } = this.props; + + return MenuButton( + { + id, + disabled, + menuId: id + "-panel", + toolboxDoc: toolbox.doc, + className: `devtools-tabbar-button command-button ${ + isChecked ? "checked" : "" + }`, + ref: "frameMenuButton", + title: description, + onCloseButton: async () => { + // Only try to unhighlight if the inspectorFront has been created already + const inspectorFront = toolbox.target.getCachedFront("inspector"); + if (inspectorFront) { + const highlighter = toolbox.getHighlighter(); + await highlighter.unhighlight(); + } + }, + }, + this.createFrameList + ); + } + + renderErrorIcon(command) { + let { errorCount, id } = command; + + if (!errorCount) { + return null; + } + + if (errorCount > 99) { + errorCount = "99+"; + } + + return button( + { + id, + className: "devtools-tabbar-button command-button toolbox-error", + onClick: () => { + if (this.props.currentToolId !== "webconsole") { + this.props.toolbox.openSplitConsole(); + } + }, + title: + this.props.currentToolId !== "webconsole" + ? this.props.L10N.getStr("toolbox.errorCountButton.tooltip") + : null, + }, + errorCount + ); + } + + highlightFrame(id) { + const { toolbox } = this.props; + if (!id) { + return; + } + + toolbox.onHighlightFrame(id); + } + + createFrameList() { + const { toolbox } = this.props; + if (toolbox.frameMap.size < 1) { + return null; + } + + const items = []; + toolbox.frameMap.forEach((frame, index) => { + const label = toolbox.target.isWebExtension + ? toolbox.target.getExtensionPathName(frame.url) + : getUnicodeUrl(frame.url); + + const item = MenuItem({ + id: frame.id.toString(), + key: "toolbox-frame-key-" + frame.id, + label, + checked: frame.id === toolbox.selectedFrameId, + onClick: () => toolbox.onIframePickerFrameSelected(frame.id), + }); + + // Always put the top level frame at the top + if (frame.isTopLevel) { + items.unshift(item); + } else { + items.push(item); + } + }); + + return MenuList( + { + id: "toolbox-frame-menu", + onHighlightedChildChange: this.highlightFrame, + }, + items + ); + } + + /** + * Render a separator. + */ + renderSeparator() { + return div({ className: "devtools-separator" }); + } + + /** + * Render the toolbox control buttons. The following props are expected: + * + * @param {string} props.focusedButton + * The id of the focused button. + * @param {string} props.currentToolId + * The id of the currently selected tool, e.g. "inspector". + * @param {Object[]} props.hostTypes + * Array of host type objects. + * @param {string} props.hostTypes[].position + * Position name. + * @param {Function} props.hostTypes[].switchHost + * Function to switch the host. + * @param {string} props.currentHostType + * The current docking configuration. + * @param {boolean} props.areDockOptionsEnabled + * They are not enabled in certain situations like when the toolbox is + * in a tab. + * @param {boolean} props.canCloseToolbox + * Do we need to add UI for closing the toolbox? We don't when the + * toolbox is undocked, for example. + * @param {boolean} props.isSplitConsoleActive + * Is the split console currently visible? + * toolbox is undocked, for example. + * @param {boolean|undefined} props.disableAutohide + * Are we disabling the behavior where pop-ups are automatically + * closed when clicking outside them? + * (Only defined for the browser toolbox.) + * @param {Function} props.selectTool + * Function to select a tool based on its id. + * @param {Function} props.toggleOptions + * Function to turn the options panel on / off. + * @param {Function} props.toggleSplitConsole + * Function to turn the split console on / off. + * @param {Function} props.toggleNoAutohide + * Function to turn the disable pop-up autohide behavior on / off. + * @param {Function} props.toggleAlwaysOnTop + * Function to turn the always on top behavior on / off. + * @param {Function} props.closeToolbox + * Completely close the toolbox. + * @param {Function} props.focusButton + * Keep a record of the currently focused button. + * @param {Object} props.L10N + * Localization interface. + * @param {Object} props.toolbox + * The devtools toolbox. Used by the MenuButton component to display + * the menu popup. + * @param {Object} refs + * The components refs object. Used to keep a reference to the MenuButton + * for the meatball menu so that we can tell it to resize its contents + * when they change. + */ + renderToolboxControls() { + const { + focusedButton, + canCloseToolbox, + closeToolbox, + focusButton, + L10N, + toolbox, + } = this.props; + + const meatballMenuButtonId = "toolbox-meatball-menu-button"; + + const meatballMenuButton = MenuButton( + { + id: meatballMenuButtonId, + menuId: meatballMenuButtonId + "-panel", + toolboxDoc: toolbox.doc, + onFocus: () => focusButton(meatballMenuButtonId), + className: "devtools-tabbar-button", + title: L10N.getStr("toolbox.meatballMenu.button.tooltip"), + tabIndex: focusedButton === meatballMenuButtonId ? "0" : "-1", + ref: "meatballMenuButton", + }, + MeatballMenu({ + ...this.props, + hostTypes: this.props.areDockOptionsEnabled ? this.props.hostTypes : [], + onResize: () => { + this.refs.meatballMenuButton.resizeContent(); + }, + }) + ); + + const closeButtonId = "toolbox-close"; + + const closeButton = canCloseToolbox + ? button({ + id: closeButtonId, + onFocus: () => focusButton(closeButtonId), + className: "devtools-tabbar-button", + title: L10N.getStr("toolbox.closebutton.tooltip"), + onClick: () => closeToolbox(), + tabIndex: focusedButton === "toolbox-close" ? "0" : "-1", + }) + : null; + + return div({ id: "toolbox-controls" }, meatballMenuButton, closeButton); + } + + /** + * The render function is kept fairly short for maintainability. See the individual + * render functions for how each of the sections is rendered. + */ + render() { + const { L10N, debugTargetData, toolbox, fluentBundles } = this.props; + const classnames = ["devtools-tabbar"]; + const startButtons = this.renderToolboxButtonsStart(); + const endButtons = this.renderToolboxButtonsEnd(); + + if (!startButtons) { + classnames.push("devtools-tabbar-has-start"); + } + if (!endButtons) { + classnames.push("devtools-tabbar-has-end"); + } + + const toolbar = this.props.canRender + ? div( + { + className: classnames.join(" "), + }, + startButtons, + ToolboxTabs(this.props), + endButtons, + this.renderToolboxControls() + ) + : div({ className: classnames.join(" ") }); + + const debugTargetInfo = debugTargetData + ? DebugTargetInfo({ + alwaysOnTop: this.props.alwaysOnTop, + focusedState: this.props.focusedState, + toggleAlwaysOnTop: this.props.toggleAlwaysOnTop, + debugTargetData, + L10N, + toolbox, + }) + : null; + + // Display the toolbar in the MBT and about:debugging MBT if we have server support for it. + const chromeDebugToolbar = toolbox.commands.targetCommand.descriptorFront + .isBrowserProcessDescriptor + ? ChromeDebugToolbar() + : null; + + return LocalizationProvider( + { bundles: fluentBundles }, + div({}, chromeDebugToolbar, debugTargetInfo, toolbar) + ); + } +} + +module.exports = ToolboxToolbar; diff --git a/devtools/client/framework/components/moz.build b/devtools/client/framework/components/moz.build new file mode 100644 index 0000000000..cb29f41ddb --- /dev/null +++ b/devtools/client/framework/components/moz.build @@ -0,0 +1,17 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + + +DevToolsModules( + "ChromeDebugToolbar.js", + "DebugTargetErrorPage.js", + "DebugTargetInfo.js", + "MeatballMenu.js", + "ToolboxController.js", + "ToolboxTab.js", + "ToolboxTabs.js", + "ToolboxToolbar.js", +) |