/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ /* vim: set ft=javascript 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/. */ /* global window, BrowserLoader */ "use strict"; const Services = require("Services"); const promise = require("promise"); const EventEmitter = require("devtools/shared/event-emitter"); const {executeSoon} = require("devtools/shared/DevToolsUtils"); const {Toolbox} = require("devtools/client/framework/toolbox"); const {PrefObserver} = require("devtools/client/shared/prefs"); const Telemetry = require("devtools/client/shared/telemetry"); const HighlightersOverlay = require("devtools/client/inspector/shared/highlighters-overlay"); const ReflowTracker = require("devtools/client/inspector/shared/reflow-tracker"); const Store = require("devtools/client/inspector/store"); const InspectorStyleChangeTracker = require("devtools/client/inspector/shared/style-change-tracker"); // Use privileged promise in panel documents to prevent having them to freeze // during toolbox destruction. See bug 1402779. const Promise = require("Promise"); loader.lazyRequireGetter(this, "initCssProperties", "devtools/shared/fronts/css-properties", true); loader.lazyRequireGetter(this, "HTMLBreadcrumbs", "devtools/client/inspector/breadcrumbs", true); loader.lazyRequireGetter(this, "KeyShortcuts", "devtools/client/shared/key-shortcuts"); loader.lazyRequireGetter(this, "InspectorSearch", "devtools/client/inspector/inspector-search", true); loader.lazyRequireGetter(this, "ToolSidebar", "devtools/client/inspector/toolsidebar", true); loader.lazyRequireGetter(this, "MarkupView", "devtools/client/inspector/markup/markup"); loader.lazyRequireGetter(this, "nodeConstants", "devtools/shared/dom-node-constants"); loader.lazyRequireGetter(this, "Menu", "devtools/client/framework/menu"); loader.lazyRequireGetter(this, "MenuItem", "devtools/client/framework/menu-item"); loader.lazyRequireGetter(this, "ExtensionSidebar", "devtools/client/inspector/extensions/extension-sidebar"); loader.lazyRequireGetter(this, "CommandUtils", "devtools/client/shared/developer-toolbar", true); loader.lazyRequireGetter(this, "clipboardHelper", "devtools/shared/platform/clipboard"); const {LocalizationHelper, localizeMarkup} = require("devtools/shared/l10n"); const INSPECTOR_L10N = new LocalizationHelper("devtools/client/locales/inspector.properties"); loader.lazyGetter(this, "TOOLBOX_L10N", function() { return new LocalizationHelper("devtools/client/locales/toolbox.properties"); }); // Sidebar dimensions const INITIAL_SIDEBAR_SIZE = 350; // If the toolbox's width is smaller than the given amount of pixels, the sidebar // automatically switches from 'landscape/horizontal' to 'portrait/vertical' mode. const PORTRAIT_MODE_WIDTH_THRESHOLD = 700; // If the toolbox's width docked to the side is smaller than the given amount of pixels, // the sidebar automatically switches from 'landscape/horizontal' to 'portrait/vertical' // mode. const SIDE_PORTAIT_MODE_WIDTH_THRESHOLD = 1000; const SHOW_THREE_PANE_TOGGLE_PREF = "devtools.inspector.three-pane-toggle"; const THREE_PANE_ENABLED_PREF = "devtools.inspector.three-pane-enabled"; const THREE_PANE_ENABLED_SCALAR = "devtools.inspector.three_pane_enabled"; /** * Represents an open instance of the Inspector for a tab. * The inspector controls the breadcrumbs, the markup view, and the sidebar * (computed view, rule view, font view and animation inspector). * * Events: * - ready * Fired when the inspector panel is opened for the first time and ready to * use * - new-root * Fired after a new root (navigation to a new page) event was fired by * the walker, and taken into account by the inspector (after the markup * view has been reloaded) * - markuploaded * Fired when the markup-view frame has loaded * - breadcrumbs-updated * Fired when the breadcrumb widget updates to a new node * - boxmodel-view-updated * Fired when the box model updates to a new node * - markupmutation * Fired after markup mutations have been processed by the markup-view * - computed-view-refreshed * Fired when the computed rules view updates to a new node * - computed-view-property-expanded * Fired when a property is expanded in the computed rules view * - computed-view-property-collapsed * Fired when a property is collapsed in the computed rules view * - computed-view-sourcelinks-updated * Fired when the stylesheet source links have been updated (when switching * to source-mapped files) * - rule-view-refreshed * Fired when the rule view updates to a new node * - rule-view-sourcelinks-updated * Fired when the stylesheet source links have been updated (when switching * to source-mapped files) */ function Inspector(toolbox) { EventEmitter.decorate(this); this._toolbox = toolbox; this._target = toolbox.target; this.panelDoc = window.document; this.panelWin = window; this.panelWin.inspector = this; // Map [panel id => panel instance] // Stores all the instances of sidebar panels like rule view, computed view, ... this._panels = new Map(); this.highlighters = new HighlightersOverlay(this); this.prefsObserver = new PrefObserver("devtools."); this.reflowTracker = new ReflowTracker(this._target); this.styleChangeTracker = new InspectorStyleChangeTracker(this); this.store = Store(); this.telemetry = new Telemetry(); // Store the URL of the target page prior to navigation in order to ensure // telemetry counts in the Grid Inspector are not double counted on reload. this.previousURL = this.target.url; this.show3PaneToggle = Services.prefs.getBoolPref(SHOW_THREE_PANE_TOGGLE_PREF); this.is3PaneModeEnabled = Services.prefs.getBoolPref(THREE_PANE_ENABLED_PREF); this.nodeMenuTriggerInfo = null; this._handleRejectionIfNotDestroyed = this._handleRejectionIfNotDestroyed.bind(this); this._onContextMenu = this._onContextMenu.bind(this); this._onBeforeNavigate = this._onBeforeNavigate.bind(this); this._onMarkupFrameLoad = this._onMarkupFrameLoad.bind(this); this._updateSearchResultsLabel = this._updateSearchResultsLabel.bind(this); this._clearSearchResultsLabel = this._clearSearchResultsLabel.bind(this); this.onDetached = this.onDetached.bind(this); this.onMarkupLoaded = this.onMarkupLoaded.bind(this); this.onNewSelection = this.onNewSelection.bind(this); this.onNewRoot = this.onNewRoot.bind(this); this.onPanelWindowResize = this.onPanelWindowResize.bind(this); this.onShowBoxModelHighlighterForNode = this.onShowBoxModelHighlighterForNode.bind(this); this.onSidebarHidden = this.onSidebarHidden.bind(this); this.onSidebarResized = this.onSidebarResized.bind(this); this.onSidebarSelect = this.onSidebarSelect.bind(this); this.onSidebarShown = this.onSidebarShown.bind(this); this.onSidebarToggle = this.onSidebarToggle.bind(this); this._target.on("will-navigate", this._onBeforeNavigate); } Inspector.prototype = { /** * open is effectively an asynchronous constructor */ async init() { // Localize all the nodes containing a data-localization attribute. localizeMarkup(this.panelDoc); this._cssProperties = await initCssProperties(this.toolbox); await this.target.makeRemote(); await this._getPageStyle(); // This may throw if the document is still loading and we are // refering to a dead about:blank document let defaultSelection = await this._getDefaultNodeForSelection() .catch(this._handleRejectionIfNotDestroyed); return this._deferredOpen(defaultSelection); }, get toolbox() { return this._toolbox; }, get inspector() { return this._toolbox.inspector; }, get walker() { return this._toolbox.walker; }, get selection() { return this._toolbox.selection; }, get highlighter() { return this._toolbox.highlighter; }, get isOuterHTMLEditable() { return this._target.client.traits.editOuterHTML; }, get hasUrlToImageDataResolver() { return this._target.client.traits.urlToImageDataResolver; }, get canGetUniqueSelector() { return this._target.client.traits.getUniqueSelector; }, get canGetCssPath() { return this._target.client.traits.getCssPath; }, get canGetXPath() { return this._target.client.traits.getXPath; }, get canGetUsedFontFaces() { return this._target.client.traits.getUsedFontFaces; }, get canPasteInnerOrAdjacentHTML() { return this._target.client.traits.pasteHTML; }, /** * Handle promise rejections for various asynchronous actions, and only log errors if * the inspector panel still exists. * This is useful to silence useless errors that happen when the inspector is closed * while still initializing (and making protocol requests). */ _handleRejectionIfNotDestroyed: function(e) { if (!this._panelDestroyer) { console.error(e); } }, _deferredOpen: async function(defaultSelection) { this.breadcrumbs = new HTMLBreadcrumbs(this); this.walker.on("new-root", this.onNewRoot); this.selection.on("new-node-front", this.onNewSelection); this.selection.on("detached-front", this.onDetached); if (this.target.isLocalTab) { // Show a warning when the debugger is paused. // We show the warning only when the inspector // is selected. this.updateDebuggerPausedWarning = () => { let notificationBox = this._toolbox.getNotificationBox(); let notification = notificationBox.getNotificationWithValue("inspector-script-paused"); if (!notification && this._toolbox.currentToolId == "inspector" && this._toolbox.threadClient.paused) { let message = INSPECTOR_L10N.getStr("debuggerPausedWarning.message"); notificationBox.appendNotification(message, "inspector-script-paused", "", notificationBox.PRIORITY_WARNING_HIGH); } if (notification && this._toolbox.currentToolId != "inspector") { notificationBox.removeNotification(notification); } if (notification && !this._toolbox.threadClient.paused) { notificationBox.removeNotification(notification); } }; this.target.on("thread-paused", this.updateDebuggerPausedWarning); this.target.on("thread-resumed", this.updateDebuggerPausedWarning); this._toolbox.on("select", this.updateDebuggerPausedWarning); this.updateDebuggerPausedWarning(); } this._initMarkup(); this.isReady = false; this.setupSearchBox(); // Setup the splitter before the sidebar is displayed so, // we don't miss any events. this.setupSplitter(); // We can display right panel with: tab bar, markup view and breadbrumb. Right after // the splitter set the right and left panel sizes, in order to avoid resizing it // during load of the inspector. this.panelDoc.getElementById("inspector-main-content").style.visibility = "visible"; this.setupSidebar(); this.setupExtensionSidebars(); await this.once("markuploaded"); this.isReady = true; // All the components are initialized. Let's select a node. if (defaultSelection) { let onAllPanelsUpdated = this.once("inspector-updated"); this.selection.setNodeFront(defaultSelection, { reason: "inspector-open" }); await onAllPanelsUpdated; await this.markup.expandNode(this.selection.nodeFront); } // Setup the toolbar only now because it may depend on the document. await this.setupToolbar(); // Log the 3 pane inspector setting on inspector open. The question we want to answer // is: // "What proportion of users use the 3 pane vs 2 pane inspector on inspector open?" this.telemetry.logKeyedScalar(THREE_PANE_ENABLED_SCALAR, this.is3PaneModeEnabled, 1); this.emit("ready"); return this; }, _onBeforeNavigate: function() { this._defaultNode = null; this.selection.setNodeFront(null); this._destroyMarkup(); this._pendingSelection = null; }, _getPageStyle: function() { return this.inspector.getPageStyle().then(pageStyle => { this.pageStyle = pageStyle; }, this._handleRejectionIfNotDestroyed); }, /** * Return a promise that will resolve to the default node for selection. */ _getDefaultNodeForSelection: function() { if (this._defaultNode) { return this._defaultNode; } let walker = this.walker; let rootNode = null; let pendingSelection = this._pendingSelection; // A helper to tell if the target has or is about to navigate. // this._pendingSelection changes on "will-navigate" and "new-root" events. let hasNavigated = () => pendingSelection !== this._pendingSelection; // If available, set either the previously selected node or the body // as default selected, else set documentElement return walker.getRootNode().then(node => { if (hasNavigated()) { return promise.reject("navigated; resolution of _defaultNode aborted"); } rootNode = node; if (this.selectionCssSelector) { return walker.querySelector(rootNode, this.selectionCssSelector); } return null; }).then(front => { if (hasNavigated()) { return promise.reject("navigated; resolution of _defaultNode aborted"); } if (front) { return front; } return walker.querySelector(rootNode, "body"); }).then(front => { if (hasNavigated()) { return promise.reject("navigated; resolution of _defaultNode aborted"); } if (front) { return front; } return this.walker.documentElement(); }).then(node => { if (hasNavigated()) { return promise.reject("navigated; resolution of _defaultNode aborted"); } this._defaultNode = node; return node; }); }, /** * Target getter. */ get target() { return this._target; }, /** * Target setter. */ set target(value) { this._target = value; }, /** * Hooks the searchbar to show result and auto completion suggestions. */ setupSearchBox: function() { this.searchBox = this.panelDoc.getElementById("inspector-searchbox"); this.searchClearButton = this.panelDoc.getElementById("inspector-searchinput-clear"); this.searchResultsLabel = this.panelDoc.getElementById("inspector-searchlabel"); this.search = new InspectorSearch(this, this.searchBox, this.searchClearButton); this.search.on("search-cleared", this._clearSearchResultsLabel); this.search.on("search-result", this._updateSearchResultsLabel); let shortcuts = new KeyShortcuts({ window: this.panelDoc.defaultView, }); let key = INSPECTOR_L10N.getStr("inspector.searchHTML.key"); shortcuts.on(key, event => { // Prevent overriding same shortcut from the computed/rule views if (event.target.closest("#sidebar-panel-ruleview") || event.target.closest("#sidebar-panel-computedview")) { return; } event.preventDefault(); this.searchBox.focus(); }); }, get searchSuggestions() { return this.search.autocompleter; }, _clearSearchResultsLabel: function(result) { return this._updateSearchResultsLabel(result, true); }, _updateSearchResultsLabel: function(result, clear = false) { let str = ""; if (!clear) { if (result) { str = INSPECTOR_L10N.getFormatStr( "inspector.searchResultsCount2", result.resultsIndex + 1, result.resultsLength); } else { str = INSPECTOR_L10N.getStr("inspector.searchResultsNone"); } } this.searchResultsLabel.textContent = str; }, get React() { return this._toolbox.React; }, get ReactDOM() { return this._toolbox.ReactDOM; }, get ReactRedux() { return this._toolbox.ReactRedux; }, get browserRequire() { return this._toolbox.browserRequire; }, get InspectorTabPanel() { if (!this._InspectorTabPanel) { this._InspectorTabPanel = this.React.createFactory(this.browserRequire( "devtools/client/inspector/components/InspectorTabPanel")); } return this._InspectorTabPanel; }, /** * Check if the inspector should use the landscape mode. * * @return {Boolean} true if the inspector should be in landscape mode. */ useLandscapeMode: function() { let { clientWidth } = this.panelDoc.getElementById("inspector-splitter-box"); return this.is3PaneModeEnabled && this.toolbox.hostType == Toolbox.HostType.SIDE ? clientWidth > SIDE_PORTAIT_MODE_WIDTH_THRESHOLD : clientWidth > PORTRAIT_MODE_WIDTH_THRESHOLD; }, /** * Build Splitter located between the main and side area of * the Inspector panel. */ setupSplitter: function() { let SplitBox = this.React.createFactory(this.browserRequire( "devtools/client/shared/components/splitter/SplitBox")); let { width, height, splitSidebarWidth } = this.getSidebarSize(); let splitter = SplitBox({ className: "inspector-sidebar-splitter", initialWidth: width, initialHeight: height, minSize: "10%", maxSize: "80%", splitterSize: 1, endPanelControl: true, startPanel: this.InspectorTabPanel({ id: "inspector-main-content" }), endPanel: SplitBox({ initialWidth: splitSidebarWidth, minSize: 10, maxSize: "80%", splitterSize: this.is3PaneModeEnabled ? 1 : 0, endPanelControl: this.is3PaneModeEnabled, startPanel: this.InspectorTabPanel({ id: "inspector-rules-container" }), endPanel: this.InspectorTabPanel({ id: "inspector-sidebar-container" }), ref: splitbox => { this.sidebarSplitBox = splitbox; }, }), vert: this.useLandscapeMode(), onControlledPanelResized: this.onSidebarResized, }); this.splitBox = this.ReactDOM.render(splitter, this.panelDoc.getElementById("inspector-splitter-box")); this.panelWin.addEventListener("resize", this.onPanelWindowResize, true); }, /** * Splitter clean up. */ teardownSplitter: function() { this.panelWin.removeEventListener("resize", this.onPanelWindowResize, true); this.sidebar.off("show", this.onSidebarShown); this.sidebar.off("hide", this.onSidebarHidden); this.sidebar.off("destroy", this.onSidebarHidden); }, /** * If Toolbox width is less than 600 px, the splitter changes its mode * to `horizontal` to support portrait view. */ onPanelWindowResize: function() { this.splitBox.setState({ vert: this.useLandscapeMode(), }); }, getSidebarSize: function() { let width; let height; let splitSidebarWidth; // Initialize splitter size from preferences. try { width = Services.prefs.getIntPref("devtools.toolsidebar-width.inspector"); height = Services.prefs.getIntPref("devtools.toolsidebar-height.inspector"); splitSidebarWidth = Services.prefs.getIntPref( "devtools.toolsidebar-width.inspector.splitsidebar"); } catch (e) { // Set width and height of the splitter. Only one // value is really useful at a time depending on the current // orientation (vertical/horizontal). // Having both is supported by the splitter component. width = this.is3PaneModeEnabled ? INITIAL_SIDEBAR_SIZE * 2 : INITIAL_SIDEBAR_SIZE; height = INITIAL_SIDEBAR_SIZE; splitSidebarWidth = INITIAL_SIDEBAR_SIZE; } return { width, height, splitSidebarWidth }; }, onSidebarHidden: function() { // Store the current splitter size to preferences. let state = this.splitBox.state; Services.prefs.setIntPref("devtools.toolsidebar-width.inspector", state.width); Services.prefs.setIntPref("devtools.toolsidebar-height.inspector", state.height); Services.prefs.setIntPref("devtools.toolsidebar-width.inspector.splitsidebar", this.sidebarSplitBox.state.width); }, onSidebarResized: function(width, height) { this.toolbox.emit("inspector-sidebar-resized", { width, height }); }, onSidebarSelect: function(toolId) { // Save the currently selected sidebar panel Services.prefs.setCharPref("devtools.inspector.activeSidebar", toolId); // Then forces the panel creation by calling getPanel // (This allows lazy loading the panels only once we select them) this.getPanel(toolId); this.toolbox.emit("inspector-sidebar-select", toolId); }, onSidebarShown: function() { let { width, height, splitSidebarWidth } = this.getSidebarSize(); this.splitBox.setState({ width, height }); this.sidebarSplitBox.setState({ width: splitSidebarWidth }); }, async onSidebarToggle() { this.is3PaneModeEnabled = !this.is3PaneModeEnabled; Services.prefs.setBoolPref(THREE_PANE_ENABLED_PREF, this.is3PaneModeEnabled); await this.setupToolbar(); await this.addRuleView(); }, /** * Adds the rule view to the middle (in landscape/horizontal mode) or bottom-left panel * (in portrait/vertical mode) or inspector sidebar depending on whether or not it is 3 * pane mode. The default tab specifies whether or not the rule view should be selected. * The defaultTab defaults to the rule view when reverting to the 2 pane mode and the * rule view is being merged back into the inspector sidebar from middle/bottom-left * panel. Otherwise, we specify the default tab when handling the sidebar setup. * * @params {String} defaultTab * Thie id of the default tab for the sidebar. */ async addRuleView(defaultTab = "ruleview") { const ruleViewSidebar = this.sidebarSplitBox.startPanelContainer; const toolboxWidth = this.panelDoc.getElementById("inspector-splitter-box").clientWidth; if (this.is3PaneModeEnabled) { // Convert to 3 pane mode by removing the rule view from the inspector sidebar // and adding the rule view to the middle (in landscape/horizontal mode) or // bottom-left (in portrait/vertical mode) panel. ruleViewSidebar.style.display = "block"; // Get the inspector sidebar's (right panel in horizontal mode or bottom panel in // vertical mode) width. const sidebarWidth = this.splitBox.state.width; // This variable represents the width of the right panel in horizontal mode or // bottom-right panel in vertical mode width in 3 pane mode. let sidebarSplitboxWidth; if (this.useLandscapeMode()) { // Whether or not doubling the inspector sidebar's (right panel in horizontal mode // or bottom panel in vertical mode) width will be bigger than half of the // toolbox's width. const canDoubleSidebarWidth = (sidebarWidth * 2) < (toolboxWidth / 2); // Resize the main split box's end panel that contains the middle and right panel. // Attempts to resize the main split box's end panel to be double the size of the // existing sidebar's width when switching to 3 pane mode. However, if the middle // and right panel's width together is greater than half of the toolbox's width, // split all 3 panels to be equally sized by resizing the end panel to be 2/3 of // the current toolbox's width. this.splitBox.setState({ width: canDoubleSidebarWidth ? sidebarWidth * 2 : toolboxWidth * 2 / 3, }); // In landscape/horizontal mode, set the right panel back to its original // inspector sidebar width if we can double the sidebar width. Otherwise, set // the width of the right panel to be 1/3 of the toolbox's width since all 3 // panels will be equally sized. sidebarSplitboxWidth = canDoubleSidebarWidth ? sidebarWidth : toolboxWidth / 3; } else { // In portrait/vertical mode, set the bottom-right panel to be 1/2 of the // toolbox's width. sidebarSplitboxWidth = toolboxWidth / 2; } // Show the splitter inside the sidebar split box. Sets the width of the inspector // sidebar and specify that the end (right in horizontal or bottom-right in // vertical) panel of the sidebar split box should be controlled when resizing. this.sidebarSplitBox.setState({ endPanelControl: true, splitterSize: 1, width: sidebarSplitboxWidth, }); // Force the rule view panel creation by calling getPanel this.getPanel("ruleview"); await this.sidebar.removeTab("ruleview"); this.ruleViewSideBar.addExistingTab( "ruleview", INSPECTOR_L10N.getStr("inspector.sidebar.ruleViewTitle"), true); this.ruleViewSideBar.show("ruleview"); } else { // Removes the rule view from the 3 pane mode and adds the rule view to the main // inspector sidebar. ruleViewSidebar.style.display = "none"; // Set the width of the split box (right panel in horziontal mode and bottom panel // in vertical mode) to be the width of the inspector sidebar. this.splitBox.setState({ width: this.useLandscapeMode() ? this.sidebarSplitBox.state.width : toolboxWidth, }); // Hide the splitter to prevent any drag events in the sidebar split box and // specify that the end (right panel in horziontal mode or bottom panel in vertical // mode) panel should be uncontrolled when resizing. this.sidebarSplitBox.setState({ endPanelControl: false, splitterSize: 0, }); this.ruleViewSideBar.hide(); await this.ruleViewSideBar.removeTab("ruleview"); this.sidebar.addExistingTab( "ruleview", INSPECTOR_L10N.getStr("inspector.sidebar.ruleViewTitle"), defaultTab == "ruleview", 0); } this.emit("ruleview-added"); }, /** * Lazily get and create panel instances displayed in the sidebar */ getPanel: function(id) { if (this._panels.has(id)) { return this._panels.get(id); } let panel; switch (id) { case "computedview": const {ComputedViewTool} = this.browserRequire("devtools/client/inspector/computed/computed"); panel = new ComputedViewTool(this, this.panelWin); break; case "ruleview": const {RuleViewTool} = require("devtools/client/inspector/rules/rules"); panel = new RuleViewTool(this, this.panelWin); break; case "boxmodel": // box-model isn't a panel on its own, it used to, now it is being used by // the layout view which retrieves an instance via getPanel. const BoxModel = require("devtools/client/inspector/boxmodel/box-model"); panel = new BoxModel(this, this.panelWin); break; default: // This is a custom panel or a non lazy-loaded one. return null; } this._panels.set(id, panel); return panel; }, /** * Build the sidebar. */ async setupSidebar() { let sidebar = this.panelDoc.getElementById("inspector-sidebar"); let options = { showAllTabsMenu: true }; if (this.show3PaneToggle) { options.sidebarToggleButton = { collapsed: !this.is3PaneModeEnabled, collapsePaneTitle: INSPECTOR_L10N.getStr("inspector.hideThreePaneMode"), expandPaneTitle: INSPECTOR_L10N.getStr("inspector.showThreePaneMode"), onClick: this.onSidebarToggle, }; } this.sidebar = new ToolSidebar(sidebar, this, "inspector", options); let ruleSideBar = this.panelDoc.getElementById("inspector-rules-sidebar"); this.ruleViewSideBar = new ToolSidebar(ruleSideBar, this, "inspector", { hideTabstripe: true }); this.sidebar.on("select", this.onSidebarSelect); let defaultTab = Services.prefs.getCharPref("devtools.inspector.activeSidebar"); if (this.is3PaneModeEnabled && defaultTab === "ruleview") { defaultTab = "computedview"; } // Append all side panels await this.addRuleView(defaultTab); // If the 3 Pane Inspector feature is disabled, use the old order: // Rules, Computed, Layout, etc. if (!this.show3PaneToggle) { this.sidebar.addExistingTab( "computedview", INSPECTOR_L10N.getStr("inspector.sidebar.computedViewTitle"), defaultTab == "computedview"); } // Inject a lazy loaded react tab by exposing a fake React object // with a lazy defined Tab thanks to `panel` being a function let layoutId = "layoutview"; let layoutTitle = INSPECTOR_L10N.getStr("inspector.sidebar.layoutViewTitle2"); this.sidebar.addTab( layoutId, layoutTitle, { props: { id: layoutId, title: layoutTitle }, panel: () => { if (!this.layoutview) { const LayoutView = this.browserRequire("devtools/client/inspector/layout/layout"); this.layoutview = new LayoutView(this, this.panelWin); } return this.layoutview.provider; } }, defaultTab == layoutId); // If the 3 Pane Inspector feature is enabled, use the new order: // Rules, Layout, Computed, etc. if (this.show3PaneToggle) { this.sidebar.addExistingTab( "computedview", INSPECTOR_L10N.getStr("inspector.sidebar.computedViewTitle"), defaultTab == "computedview"); } if (Services.prefs.getBoolPref("devtools.changesview.enabled")) { // Inject a lazy loaded react tab by exposing a fake React object // with a lazy defined Tab thanks to `panel` being a function let changesId = "changesview"; let changesTitle = INSPECTOR_L10N.getStr("inspector.sidebar.changesViewTitle"); this.sidebar.addTab( changesId, changesTitle, { props: { id: changesId, title: changesTitle }, panel: () => { if (!this.changesview) { const ChangesView = this.browserRequire("devtools/client/inspector/changes/changes"); this.changesview = new ChangesView(this, this.panelWin); } return this.changesview.provider; } }, defaultTab == changesId); } if (Services.prefs.getBoolPref("devtools.eventsview.enabled")) { // Inject a lazy loaded react tab by exposing a fake React object // with a lazy defined Tab thanks to `panel` being a function let eventsId = "eventsview"; let eventsTitle = INSPECTOR_L10N.getStr("inspector.sidebar.eventsViewTitle"); this.sidebar.addTab( eventsId, eventsTitle, { props: { id: eventsId, title: eventsTitle }, panel: () => { if (!this.eventview) { const EventsView = this.browserRequire("devtools/client/inspector/events/events"); this.eventsview = new EventsView(this, this.panelWin); } return this.eventsview.provider; } }, defaultTab == eventsId); } if (this.target.form.animationsActor) { const animationTitle = INSPECTOR_L10N.getStr("inspector.sidebar.animationInspectorTitle"); if (Services.prefs.getBoolPref("devtools.new-animationinspector.enabled")) { const animationId = "newanimationinspector"; this.sidebar.addTab( animationId, animationTitle, { props: { id: animationId, title: animationTitle }, panel: () => { const AnimationInspector = this.browserRequire("devtools/client/inspector/animation/animation"); this.animationinspector = new AnimationInspector(this, this.panelWin); return this.animationinspector.provider; } }, defaultTab == animationId); } else { this.sidebar.addFrameTab( "animationinspector", animationTitle, "chrome://devtools/content/inspector/animation-old/animation-inspector.xhtml", defaultTab == "animationinspector"); } } if (this.canGetUsedFontFaces) { // Inject a lazy loaded react tab by exposing a fake React object // with a lazy defined Tab thanks to `panel` being a function let fontId = "fontinspector"; let fontTitle = INSPECTOR_L10N.getStr("inspector.sidebar.fontInspectorTitle"); this.sidebar.addTab( fontId, fontTitle, { props: { id: fontId, title: fontTitle }, panel: () => { if (!this.fontinspector) { const FontInspector = this.browserRequire("devtools/client/inspector/fonts/fonts"); this.fontinspector = new FontInspector(this, this.panelWin); } return this.fontinspector.provider; } }, defaultTab == fontId); } // Persist splitter state in preferences. this.sidebar.on("show", this.onSidebarShown); this.sidebar.on("hide", this.onSidebarHidden); this.sidebar.on("destroy", this.onSidebarHidden); this.sidebar.show(defaultTab); }, /** * Setup any extension sidebar already registered to the toolbox when the inspector. * has been created for the first time. */ setupExtensionSidebars() { for (const [sidebarId, {title}] of this.toolbox.inspectorExtensionSidebars) { this.addExtensionSidebar(sidebarId, {title}); } }, /** * Create a side-panel tab controlled by an extension * using the devtools.panels.elements.createSidebarPane and sidebar object API * * @param {String} id * An unique id for the sidebar tab. * @param {Object} options * @param {String} options.title * The tab title */ addExtensionSidebar: function(id, {title}) { if (this._panels.has(id)) { throw new Error(`Cannot create an extension sidebar for the existent id: ${id}`); } const extensionSidebar = new ExtensionSidebar(this, {id, title}); // TODO(rpl): pass some extension metadata (e.g. extension name and icon) to customize // the render of the extension title (e.g. use the icon in the sidebar and show the // extension name in a tooltip). this.addSidebarTab(id, title, extensionSidebar.provider, false); this._panels.set(id, extensionSidebar); // Emit the created ExtensionSidebar instance to the listeners registered // on the toolbox by the "devtools.panels.elements" WebExtensions API. this.toolbox.emit(`extension-sidebar-created-${id}`, extensionSidebar); }, /** * Remove and destroy a side-panel tab controlled by an extension (e.g. when the * extension has been disable/uninstalled while the toolbox and inspector were * still open). * * @param {String} id * The id of the sidebar tab to destroy. */ removeExtensionSidebar: function(id) { if (!this._panels.has(id)) { throw new Error(`Unable to find a sidebar panel with id "${id}"`); } const panel = this._panels.get(id); if (!(panel instanceof ExtensionSidebar)) { throw new Error(`The sidebar panel with id "${id}" is not an ExtensionSidebar`); } this._panels.delete(id); this.sidebar.removeTab(id); panel.destroy(); }, /** * Register a side-panel tab. This API can be used outside of * DevTools (e.g. from an extension) as well as by DevTools * code base. * * @param {string} tab uniq id * @param {string} title tab title * @param {React.Component} panel component. See `InspectorPanelTab` as an example. * @param {boolean} selected true if the panel should be selected */ addSidebarTab: function(id, title, panel, selected) { this.sidebar.addTab(id, title, panel, selected); }, /** * Method to check whether the document is a HTML document and * pickColorFromPage method is available or not. * * @return {Boolean} true if the eyedropper highlighter is supported by the current * document. */ async supportsEyeDropper() { try { let hasSupportsHighlighters = await this.target.actorHasMethod("inspector", "supportsHighlighters"); let supportsHighlighters; if (hasSupportsHighlighters) { supportsHighlighters = await this.inspector.supportsHighlighters(); } else { // If the actor does not provide the supportsHighlighter method, fallback to // check if the selected node's document is a HTML document. let { nodeFront } = this.selection; supportsHighlighters = nodeFront && nodeFront.isInHTMLDocument; } return supportsHighlighters; } catch (e) { console.error(e); return false; } }, async setupToolbar() { this.teardownToolbar(); // Setup the add-node button. this.addNode = this.addNode.bind(this); this.addNodeButton = this.panelDoc.getElementById("inspector-element-add-button"); this.addNodeButton.addEventListener("click", this.addNode); // Setup the eye-dropper icon if we're in an HTML document and we have actor support. let canShowEyeDropper = await this.supportsEyeDropper(); // Bail out if the inspector was destroyed in the meantime and panelDoc is no longer // available. if (!this.panelDoc) { return; } if (canShowEyeDropper) { this.onEyeDropperDone = this.onEyeDropperDone.bind(this); this.onEyeDropperButtonClicked = this.onEyeDropperButtonClicked.bind(this); this.eyeDropperButton = this.panelDoc .getElementById("inspector-eyedropper-toggle"); this.eyeDropperButton.disabled = false; this.eyeDropperButton.title = INSPECTOR_L10N.getStr("inspector.eyedropper.label"); this.eyeDropperButton.addEventListener("click", this.onEyeDropperButtonClicked); } else { let eyeDropperButton = this.panelDoc.getElementById("inspector-eyedropper-toggle"); eyeDropperButton.disabled = true; eyeDropperButton.title = INSPECTOR_L10N.getStr("eyedropper.disabled.title"); } }, teardownToolbar: function() { if (this.addNodeButton) { this.addNodeButton.removeEventListener("click", this.addNode); this.addNodeButton = null; } if (this.eyeDropperButton) { this.eyeDropperButton.removeEventListener("click", this.onEyeDropperButtonClicked); this.eyeDropperButton = null; } }, /** * Reset the inspector on new root mutation. */ onNewRoot: function() { // Record new-root timing for telemetry this._newRootStart = this.panelWin.performance.now(); this._defaultNode = null; this.selection.setNodeFront(null); this._destroyMarkup(); let onNodeSelected = defaultNode => { // Cancel this promise resolution as a new one had // been queued up. if (this._pendingSelection != onNodeSelected) { return; } this._pendingSelection = null; this.selection.setNodeFront(defaultNode, { reason: "navigateaway" }); this._initMarkup(); this.once("markuploaded", this.onMarkupLoaded); // Setup the toolbar again, since its content may depend on the current document. this.setupToolbar(); }; this._pendingSelection = onNodeSelected; this._getDefaultNodeForSelection() .then(onNodeSelected, this._handleRejectionIfNotDestroyed); }, /** * Handler for "markuploaded" event fired on a new root mutation and after the markup * view is initialized. Expands the current selected node and restores the saved * highlighter state. */ async onMarkupLoaded() { if (!this.markup) { return; } let onExpand = this.markup.expandNode(this.selection.nodeFront); // Restore the highlighter states prior to emitting "new-root". await Promise.all([ this.highlighters.restoreFlexboxState(), this.highlighters.restoreGridState() ]); this.emit("new-root"); // Wait for full expand of the selected node in order to ensure // the markup view is fully emitted before firing 'reloaded'. // 'reloaded' is used to know when the panel is fully updated // after a page reload. await onExpand; this.emit("reloaded"); // Record the time between new-root event and inspector fully loaded. if (this._newRootStart) { // Only log the timing when inspector is not destroyed and is in foreground. if (this.toolbox && this.toolbox.currentToolId == "inspector") { let delay = this.panelWin.performance.now() - this._newRootStart; let telemetryKey = "DEVTOOLS_INSPECTOR_NEW_ROOT_TO_RELOAD_DELAY_MS"; let histogram = Services.telemetry.getHistogramById(telemetryKey); histogram.add(delay); } delete this._newRootStart; } }, _selectionCssSelector: null, /** * Set the currently selected node unique css selector. * Will store the current target url along with it to allow pre-selection at * reload */ set selectionCssSelector(cssSelector = null) { if (this._panelDestroyer) { return; } this._selectionCssSelector = { selector: cssSelector, url: this._target.url }; }, /** * Get the current selection unique css selector if any, that is, if a node * is actually selected and that node has been selected while on the same url */ get selectionCssSelector() { if (this._selectionCssSelector && this._selectionCssSelector.url === this._target.url) { return this._selectionCssSelector.selector; } return null; }, /** * Can a new HTML element be inserted into the currently selected element? * @return {Boolean} */ canAddHTMLChild: function() { let selection = this.selection; // Don't allow to insert an element into these elements. This should only // contain elements where walker.insertAdjacentHTML has no effect. let invalidTagNames = ["html", "iframe"]; return selection.isHTMLNode() && selection.isElementNode() && !selection.isPseudoElementNode() && !selection.isAnonymousNode() && !invalidTagNames.includes( selection.nodeFront.nodeName.toLowerCase()); }, /** * When a new node is selected. */ onNewSelection: function(value, reason) { if (reason === "selection-destroy") { return; } // Wait for all the known tools to finish updating and then let the // client know. let selection = this.selection.nodeFront; // Update the state of the add button in the toolbar depending on the // current selection. let btn = this.panelDoc.querySelector("#inspector-element-add-button"); if (this.canAddHTMLChild()) { btn.removeAttribute("disabled"); } else { btn.setAttribute("disabled", "true"); } // On any new selection made by the user, store the unique css selector // of the selected node so it can be restored after reload of the same page if (this.canGetUniqueSelector && this.selection.isElementNode()) { selection.getUniqueSelector().then(selector => { this.selectionCssSelector = selector; }, this._handleRejectionIfNotDestroyed); } let selfUpdate = this.updating("inspector-panel"); executeSoon(() => { try { selfUpdate(selection); } catch (ex) { console.error(ex); } }); }, /** * Delay the "inspector-updated" notification while a tool * is updating itself. Returns a function that must be * invoked when the tool is done updating with the node * that the tool is viewing. */ updating: function(name) { if (this._updateProgress && this._updateProgress.node != this.selection.nodeFront) { this.cancelUpdate(); } if (!this._updateProgress) { // Start an update in progress. let self = this; this._updateProgress = { node: this.selection.nodeFront, outstanding: new Set(), checkDone: function() { if (this !== self._updateProgress) { return; } // Cancel update if there is no `selection` anymore. // It can happen if the inspector panel is already destroyed. if (!self.selection || (this.node !== self.selection.nodeFront)) { self.cancelUpdate(); return; } if (this.outstanding.size !== 0) { return; } self._updateProgress = null; self.emit("inspector-updated", name); }, }; } let progress = this._updateProgress; let done = function() { progress.outstanding.delete(done); progress.checkDone(); }; progress.outstanding.add(done); return done; }, /** * Cancel notification of inspector updates. */ cancelUpdate: function() { this._updateProgress = null; }, /** * When a node is deleted, select its parent node or the defaultNode if no * parent is found (may happen when deleting an iframe inside which the * node was selected). */ onDetached: function(parentNode) { this.breadcrumbs.cutAfter(this.breadcrumbs.indexOf(parentNode)); let nodeFront = parentNode ? parentNode : this._defaultNode; this.selection.setNodeFront(nodeFront, { reason: "detached" }); }, /** * Destroy the inspector. */ destroy: function() { if (this._panelDestroyer) { return this._panelDestroyer; } if (this.walker) { this.walker.off("new-root", this.onNewRoot); this.pageStyle = null; } this.cancelUpdate(); this.target.off("will-navigate", this._onBeforeNavigate); this.target.off("thread-paused", this.updateDebuggerPausedWarning); this.target.off("thread-resumed", this.updateDebuggerPausedWarning); this._toolbox.off("select", this.updateDebuggerPausedWarning); for (let [, panel] of this._panels) { panel.destroy(); } this._panels.clear(); if (this.layoutview) { this.layoutview.destroy(); } if (this.fontinspector) { this.fontinspector.destroy(); } if (this.animationinspector) { this.animationinspector.destroy(); } let cssPropertiesDestroyer = this._cssProperties.front.destroy(); this.sidebar.off("select", this.onSidebarSelect); let sidebarDestroyer = this.sidebar.destroy(); let ruleViewSideBarDestroyer = this.ruleViewSideBar ? this.ruleViewSideBar.destroy() : null; this.teardownSplitter(); this.teardownToolbar(); this.breadcrumbs.destroy(); this.selection.off("new-node-front", this.onNewSelection); this.selection.off("detached-front", this.onDetached); let markupDestroyer = this._destroyMarkup(); let highlighterDestroyer = this.highlighters.destroy(); this.prefsObserver.destroy(); this.reflowTracker.destroy(); this.styleChangeTracker.destroy(); this.search.destroy(); this._toolbox = null; this.breadcrumbs = null; this.highlighters = null; this.is3PaneModeEnabled = null; this.panelDoc = null; this.panelWin.inspector = null; this.panelWin = null; this.prefsObserver = null; this.resultsLength = null; this.search = null; this.searchBox = null; this.show3PaneToggle = null; this.sidebar = null; this.store = null; this.target = null; this._panelDestroyer = promise.all([ highlighterDestroyer, cssPropertiesDestroyer, markupDestroyer, sidebarDestroyer, ruleViewSideBarDestroyer ]); return this._panelDestroyer; }, /** * Returns the clipboard content if it is appropriate for pasting * into the current node's outer HTML, otherwise returns null. */ _getClipboardContentForPaste: function() { let content = clipboardHelper.getText(); if (content && content.trim().length > 0) { return content; } return null; }, _onContextMenu: function(e) { if (!(e.originalTarget instanceof Element) || e.originalTarget.closest("input[type=text]") || e.originalTarget.closest("input:not([type])") || e.originalTarget.closest("textarea")) { return; } e.stopPropagation(); e.preventDefault(); this._openMenu({ screenX: e.screenX, screenY: e.screenY, target: e.target, }); }, _openMenu: function({ target, screenX = 0, screenY = 0 } = { }) { if (this.selection.isSlotted()) { // Slotted elements should not show any context menu. return null; } let markupContainer = this.markup.getContainer(this.selection.nodeFront); this.contextMenuTarget = target; this.nodeMenuTriggerInfo = markupContainer && markupContainer.editor.getInfoAtNode(target); let isSelectionElement = this.selection.isElementNode() && !this.selection.isPseudoElementNode(); let isEditableElement = isSelectionElement && !this.selection.isAnonymousNode(); let isDuplicatableElement = isSelectionElement && !this.selection.isAnonymousNode() && !this.selection.isRoot(); let isScreenshotable = isSelectionElement && this.canGetUniqueSelector && this.selection.nodeFront.isTreeDisplayed; let menu = new Menu(); menu.append(new MenuItem({ id: "node-menu-edithtml", label: INSPECTOR_L10N.getStr("inspectorHTMLEdit.label"), accesskey: INSPECTOR_L10N.getStr("inspectorHTMLEdit.accesskey"), disabled: !isEditableElement || !this.isOuterHTMLEditable, click: () => this.editHTML(), })); menu.append(new MenuItem({ id: "node-menu-add", label: INSPECTOR_L10N.getStr("inspectorAddNode.label"), accesskey: INSPECTOR_L10N.getStr("inspectorAddNode.accesskey"), disabled: !this.canAddHTMLChild(), click: () => this.addNode(), })); menu.append(new MenuItem({ id: "node-menu-duplicatenode", label: INSPECTOR_L10N.getStr("inspectorDuplicateNode.label"), disabled: !isDuplicatableElement, click: () => this.duplicateNode(), })); menu.append(new MenuItem({ id: "node-menu-delete", label: INSPECTOR_L10N.getStr("inspectorHTMLDelete.label"), accesskey: INSPECTOR_L10N.getStr("inspectorHTMLDelete.accesskey"), disabled: !this.isDeletable(this.selection.nodeFront), click: () => this.deleteNode(), })); menu.append(new MenuItem({ label: INSPECTOR_L10N.getStr("inspectorAttributesSubmenu.label"), accesskey: INSPECTOR_L10N.getStr("inspectorAttributesSubmenu.accesskey"), submenu: this._getAttributesSubmenu(isEditableElement), })); menu.append(new MenuItem({ type: "separator", })); // Set the pseudo classes for (let name of ["hover", "active", "focus"]) { let menuitem = new MenuItem({ id: "node-menu-pseudo-" + name, label: name, type: "checkbox", click: this.togglePseudoClass.bind(this, ":" + name), }); if (isSelectionElement) { let checked = this.selection.nodeFront.hasPseudoClassLock(":" + name); menuitem.checked = checked; } else { menuitem.disabled = true; } menu.append(menuitem); } menu.append(new MenuItem({ type: "separator", })); menu.append(new MenuItem({ label: INSPECTOR_L10N.getStr("inspectorCopyHTMLSubmenu.label"), submenu: this._getCopySubmenu(markupContainer, isSelectionElement), })); menu.append(new MenuItem({ label: INSPECTOR_L10N.getStr("inspectorPasteHTMLSubmenu.label"), submenu: this._getPasteSubmenu(isEditableElement), })); menu.append(new MenuItem({ type: "separator", })); let isNodeWithChildren = this.selection.isNode() && markupContainer.hasChildren; menu.append(new MenuItem({ id: "node-menu-expand", label: INSPECTOR_L10N.getStr("inspectorExpandNode.label"), disabled: !isNodeWithChildren, click: () => this.expandNode(), })); menu.append(new MenuItem({ id: "node-menu-collapse", label: INSPECTOR_L10N.getStr("inspectorCollapseAll.label"), disabled: !isNodeWithChildren || !markupContainer.expanded, click: () => this.collapseAll(), })); menu.append(new MenuItem({ type: "separator", })); menu.append(new MenuItem({ id: "node-menu-scrollnodeintoview", label: INSPECTOR_L10N.getStr("inspectorScrollNodeIntoView.label"), accesskey: INSPECTOR_L10N.getStr("inspectorScrollNodeIntoView.accesskey"), disabled: !isSelectionElement, click: () => this.scrollNodeIntoView(), })); menu.append(new MenuItem({ id: "node-menu-screenshotnode", label: INSPECTOR_L10N.getStr("inspectorScreenshotNode.label"), disabled: !isScreenshotable, click: () => this.screenshotNode().catch(console.error), })); menu.append(new MenuItem({ id: "node-menu-useinconsole", label: INSPECTOR_L10N.getStr("inspectorUseInConsole.label"), click: () => this.useInConsole(), })); menu.append(new MenuItem({ id: "node-menu-showdomproperties", label: INSPECTOR_L10N.getStr("inspectorShowDOMProperties.label"), click: () => this.showDOMProperties(), })); this.buildA11YMenuItem(menu); let nodeLinkMenuItems = this._getNodeLinkMenuItems(); if (nodeLinkMenuItems.filter(item => item.visible).length > 0) { menu.append(new MenuItem({ id: "node-menu-link-separator", type: "separator", })); } for (let menuitem of nodeLinkMenuItems) { menu.append(menuitem); } menu.popup(screenX, screenY, this._toolbox); return menu; }, buildA11YMenuItem: function(menu) { if (!(this.selection.isElementNode() || this.selection.isTextNode()) || !Services.prefs.getBoolPref("devtools.accessibility.enabled")) { return; } const showA11YPropsItem = new MenuItem({ id: "node-menu-showaccessibilityproperties", label: INSPECTOR_L10N.getStr("inspectorShowAccessibilityProperties.label"), click: () => this.showAccessibilityProperties(), disabled: true }); this._updateA11YMenuItem(showA11YPropsItem); menu.append(showA11YPropsItem); }, _updateA11YMenuItem: async function(menuItem) { const hasMethod = await this.target.actorHasMethod("domwalker", "hasAccessibilityProperties"); if (!hasMethod) { return; } const hasA11YProps = await this.walker.hasAccessibilityProperties( this.selection.nodeFront); if (hasA11YProps) { this._toolbox.doc.getElementById(menuItem.id).disabled = menuItem.disabled = false; } this.emit("node-menu-updated"); }, _getCopySubmenu: function(markupContainer, isSelectionElement) { let copySubmenu = new Menu(); copySubmenu.append(new MenuItem({ id: "node-menu-copyinner", label: INSPECTOR_L10N.getStr("inspectorCopyInnerHTML.label"), accesskey: INSPECTOR_L10N.getStr("inspectorCopyInnerHTML.accesskey"), disabled: !isSelectionElement, click: () => this.copyInnerHTML(), })); copySubmenu.append(new MenuItem({ id: "node-menu-copyouter", label: INSPECTOR_L10N.getStr("inspectorCopyOuterHTML.label"), accesskey: INSPECTOR_L10N.getStr("inspectorCopyOuterHTML.accesskey"), disabled: !isSelectionElement, click: () => this.copyOuterHTML(), })); copySubmenu.append(new MenuItem({ id: "node-menu-copyuniqueselector", label: INSPECTOR_L10N.getStr("inspectorCopyCSSSelector.label"), accesskey: INSPECTOR_L10N.getStr("inspectorCopyCSSSelector.accesskey"), disabled: !isSelectionElement, hidden: !this.canGetUniqueSelector, click: () => this.copyUniqueSelector(), })); copySubmenu.append(new MenuItem({ id: "node-menu-copycsspath", label: INSPECTOR_L10N.getStr("inspectorCopyCSSPath.label"), accesskey: INSPECTOR_L10N.getStr("inspectorCopyCSSPath.accesskey"), disabled: !isSelectionElement, hidden: !this.canGetCssPath, click: () => this.copyCssPath(), })); copySubmenu.append(new MenuItem({ id: "node-menu-copyxpath", label: INSPECTOR_L10N.getStr("inspectorCopyXPath.label"), accesskey: INSPECTOR_L10N.getStr("inspectorCopyXPath.accesskey"), disabled: !isSelectionElement, hidden: !this.canGetXPath, click: () => this.copyXPath(), })); copySubmenu.append(new MenuItem({ id: "node-menu-copyimagedatauri", label: INSPECTOR_L10N.getStr("inspectorImageDataUri.label"), disabled: !isSelectionElement || !markupContainer || !markupContainer.isPreviewable(), click: () => this.copyImageDataUri(), })); return copySubmenu; }, _getPasteSubmenu: function(isEditableElement) { let isPasteable = isEditableElement && this._getClipboardContentForPaste(); let disableAdjacentPaste = !isPasteable || !this.canPasteInnerOrAdjacentHTML || this.selection.isRoot() || this.selection.isBodyNode() || this.selection.isHeadNode(); let disableFirstLastPaste = !isPasteable || !this.canPasteInnerOrAdjacentHTML || (this.selection.isHTMLNode() && this.selection.isRoot()); let pasteSubmenu = new Menu(); pasteSubmenu.append(new MenuItem({ id: "node-menu-pasteinnerhtml", label: INSPECTOR_L10N.getStr("inspectorPasteInnerHTML.label"), accesskey: INSPECTOR_L10N.getStr("inspectorPasteInnerHTML.accesskey"), disabled: !isPasteable || !this.canPasteInnerOrAdjacentHTML, click: () => this.pasteInnerHTML(), })); pasteSubmenu.append(new MenuItem({ id: "node-menu-pasteouterhtml", label: INSPECTOR_L10N.getStr("inspectorPasteOuterHTML.label"), accesskey: INSPECTOR_L10N.getStr("inspectorPasteOuterHTML.accesskey"), disabled: !isPasteable || !this.isOuterHTMLEditable, click: () => this.pasteOuterHTML(), })); pasteSubmenu.append(new MenuItem({ id: "node-menu-pastebefore", label: INSPECTOR_L10N.getStr("inspectorHTMLPasteBefore.label"), accesskey: INSPECTOR_L10N.getStr("inspectorHTMLPasteBefore.accesskey"), disabled: disableAdjacentPaste, click: () => this.pasteAdjacentHTML("beforeBegin"), })); pasteSubmenu.append(new MenuItem({ id: "node-menu-pasteafter", label: INSPECTOR_L10N.getStr("inspectorHTMLPasteAfter.label"), accesskey: INSPECTOR_L10N.getStr("inspectorHTMLPasteAfter.accesskey"), disabled: disableAdjacentPaste, click: () => this.pasteAdjacentHTML("afterEnd"), })); pasteSubmenu.append(new MenuItem({ id: "node-menu-pastefirstchild", label: INSPECTOR_L10N.getStr("inspectorHTMLPasteFirstChild.label"), accesskey: INSPECTOR_L10N.getStr("inspectorHTMLPasteFirstChild.accesskey"), disabled: disableFirstLastPaste, click: () => this.pasteAdjacentHTML("afterBegin"), })); pasteSubmenu.append(new MenuItem({ id: "node-menu-pastelastchild", label: INSPECTOR_L10N.getStr("inspectorHTMLPasteLastChild.label"), accesskey: INSPECTOR_L10N.getStr("inspectorHTMLPasteLastChild.accesskey"), disabled: disableFirstLastPaste, click: () => this.pasteAdjacentHTML("beforeEnd"), })); return pasteSubmenu; }, _getAttributesSubmenu: function(isEditableElement) { let attributesSubmenu = new Menu(); let nodeInfo = this.nodeMenuTriggerInfo; let isAttributeClicked = isEditableElement && nodeInfo && nodeInfo.type === "attribute"; attributesSubmenu.append(new MenuItem({ id: "node-menu-add-attribute", label: INSPECTOR_L10N.getStr("inspectorAddAttribute.label"), accesskey: INSPECTOR_L10N.getStr("inspectorAddAttribute.accesskey"), disabled: !isEditableElement, click: () => this.onAddAttribute(), })); attributesSubmenu.append(new MenuItem({ id: "node-menu-copy-attribute", label: INSPECTOR_L10N.getFormatStr("inspectorCopyAttributeValue.label", isAttributeClicked ? `${nodeInfo.value}` : ""), accesskey: INSPECTOR_L10N.getStr("inspectorCopyAttributeValue.accesskey"), disabled: !isAttributeClicked, click: () => this.onCopyAttributeValue(), })); attributesSubmenu.append(new MenuItem({ id: "node-menu-edit-attribute", label: INSPECTOR_L10N.getFormatStr("inspectorEditAttribute.label", isAttributeClicked ? `${nodeInfo.name}` : ""), accesskey: INSPECTOR_L10N.getStr("inspectorEditAttribute.accesskey"), disabled: !isAttributeClicked, click: () => this.onEditAttribute(), })); attributesSubmenu.append(new MenuItem({ id: "node-menu-remove-attribute", label: INSPECTOR_L10N.getFormatStr("inspectorRemoveAttribute.label", isAttributeClicked ? `${nodeInfo.name}` : ""), accesskey: INSPECTOR_L10N.getStr("inspectorRemoveAttribute.accesskey"), disabled: !isAttributeClicked, click: () => this.onRemoveAttribute(), })); return attributesSubmenu; }, /** * Link menu items can be shown or hidden depending on the context and * selected node, and their labels can vary. * * @return {Array} list of visible menu items related to links. */ _getNodeLinkMenuItems: function() { let linkFollow = new MenuItem({ id: "node-menu-link-follow", visible: false, click: () => this.onFollowLink(), }); let linkCopy = new MenuItem({ id: "node-menu-link-copy", visible: false, click: () => this.onCopyLink(), }); // Get information about the right-clicked node. let popupNode = this.contextMenuTarget; if (!popupNode || !popupNode.classList.contains("link")) { return [linkFollow, linkCopy]; } let type = popupNode.dataset.type; if ((type === "uri" || type === "cssresource" || type === "jsresource")) { // Links can't be opened in new tabs in the browser toolbox. if (type === "uri" && !this.target.chrome) { linkFollow.visible = true; linkFollow.label = INSPECTOR_L10N.getStr( "inspector.menu.openUrlInNewTab.label"); } else if (type === "cssresource") { linkFollow.visible = true; linkFollow.label = TOOLBOX_L10N.getStr( "toolbox.viewCssSourceInStyleEditor.label"); } else if (type === "jsresource") { linkFollow.visible = true; linkFollow.label = TOOLBOX_L10N.getStr( "toolbox.viewJsSourceInDebugger.label"); } linkCopy.visible = true; linkCopy.label = INSPECTOR_L10N.getStr( "inspector.menu.copyUrlToClipboard.label"); } else if (type === "idref") { linkFollow.visible = true; linkFollow.label = INSPECTOR_L10N.getFormatStr( "inspector.menu.selectElement.label", popupNode.dataset.link); } return [linkFollow, linkCopy]; }, _initMarkup: function() { let doc = this.panelDoc; this._markupBox = doc.getElementById("markup-box"); // create tool iframe this._markupFrame = doc.createElement("iframe"); this._markupFrame.setAttribute("flex", "1"); // This is needed to enable tooltips inside the iframe document. this._markupFrame.setAttribute("tooltip", "aHTMLTooltip"); this._markupFrame.addEventListener("contextmenu", this._onContextMenu); this._markupBox.style.visibility = "hidden"; this._markupBox.appendChild(this._markupFrame); this._markupFrame.addEventListener("load", this._onMarkupFrameLoad, true); this._markupFrame.setAttribute("src", "markup/markup.xhtml"); this._markupFrame.setAttribute("aria-label", INSPECTOR_L10N.getStr("inspector.panelLabel.markupView")); }, _onMarkupFrameLoad: function() { this._markupFrame.removeEventListener("load", this._onMarkupFrameLoad, true); this._markupFrame.contentWindow.focus(); this.markup = new MarkupView(this, this._markupFrame, this._toolbox.win); this._markupBox.style.visibility = "visible"; this.emit("markuploaded"); }, _destroyMarkup: function() { let destroyPromise; if (this._markupFrame) { this._markupFrame.removeEventListener("load", this._onMarkupFrameLoad, true); this._markupFrame.removeEventListener("contextmenu", this._onContextMenu); } if (this.markup) { destroyPromise = this.markup.destroy(); this.markup = null; } else { destroyPromise = promise.resolve(); } if (this._markupFrame) { this._markupFrame.remove(); this._markupFrame = null; } this._markupBox = null; return destroyPromise; }, onEyeDropperButtonClicked: function() { this.eyeDropperButton.classList.contains("checked") ? this.hideEyeDropper() : this.showEyeDropper(); }, startEyeDropperListeners: function() { this.inspector.once("color-pick-canceled", this.onEyeDropperDone); this.inspector.once("color-picked", this.onEyeDropperDone); this.walker.once("new-root", this.onEyeDropperDone); }, stopEyeDropperListeners: function() { this.inspector.off("color-pick-canceled", this.onEyeDropperDone); this.inspector.off("color-picked", this.onEyeDropperDone); this.walker.off("new-root", this.onEyeDropperDone); }, onEyeDropperDone: function() { this.eyeDropperButton.classList.remove("checked"); this.stopEyeDropperListeners(); }, /** * Show the eyedropper on the page. * @return {Promise} resolves when the eyedropper is visible. */ showEyeDropper: function() { // The eyedropper button doesn't exist, most probably because the actor doesn't // support the pickColorFromPage, or because the page isn't HTML. if (!this.eyeDropperButton) { return null; } this.telemetry.toolOpened("toolbareyedropper"); this.eyeDropperButton.classList.add("checked"); this.startEyeDropperListeners(); return this.inspector.pickColorFromPage(this.toolbox, {copyOnSelect: true}) .catch(console.error); }, /** * Hide the eyedropper. * @return {Promise} resolves when the eyedropper is hidden. */ hideEyeDropper: function() { // The eyedropper button doesn't exist, most probably because the page isn't HTML. if (!this.eyeDropperButton) { return null; } this.eyeDropperButton.classList.remove("checked"); this.stopEyeDropperListeners(); return this.inspector.cancelPickColorFromPage() .catch(console.error); }, /** * Create a new node as the last child of the current selection, expand the * parent and select the new node. */ async addNode() { if (!this.canAddHTMLChild()) { return; } let html = "
"; // Insert the html and expect a childList markup mutation. let onMutations = this.once("markupmutation"); await this.walker.insertAdjacentHTML(this.selection.nodeFront, "beforeEnd", html); await onMutations; // Expand the parent node. this.markup.expandNode(this.selection.nodeFront); }, /** * Toggle a pseudo class. */ togglePseudoClass: function(pseudo) { if (this.selection.isElementNode()) { let node = this.selection.nodeFront; if (node.hasPseudoClassLock(pseudo)) { return this.walker.removePseudoClassLock(node, pseudo, {parents: true}); } let hierarchical = pseudo == ":hover" || pseudo == ":active"; return this.walker.addPseudoClassLock(node, pseudo, {parents: hierarchical}); } return promise.resolve(); }, /** * Show DOM properties */ showDOMProperties: function() { this._toolbox.openSplitConsole().then(() => { let panel = this._toolbox.getPanel("webconsole"); let jsterm = panel.hud.jsterm; jsterm.execute("inspect($0)"); jsterm.focus(); }); }, /** * Show Accessibility properties for currently selected node */ async showAccessibilityProperties() { let a11yPanel = await this._toolbox.selectTool("accessibility"); // Select the accessible object in the panel and wait for the event that // tells us it has been done. let onSelected = a11yPanel.once("new-accessible-front-selected"); a11yPanel.selectAccessibleForNode(this.selection.nodeFront, "inspector-context-menu"); await onSelected; }, /** * Use in Console. * * Takes the currently selected node in the inspector and assigns it to a * temp variable on the content window. Also opens the split console and * autofills it with the temp variable. */ useInConsole: function() { this._toolbox.openSplitConsole().then(() => { let panel = this._toolbox.getPanel("webconsole"); let jsterm = panel.hud.jsterm; let evalString = `{ let i = 0; while (window.hasOwnProperty("temp" + i) && i < 1000) { i++; } window["temp" + i] = $0; "temp" + i; }`; let options = { selectedNodeActor: this.selection.nodeFront.actorID, }; jsterm.requestEvaluation(evalString, options).then((res) => { jsterm.setInputValue(res.result); this.emit("console-var-ready"); }); }); }, /** * Edit the outerHTML of the selected Node. */ editHTML: function() { if (!this.selection.isNode()) { return; } if (this.markup) { this.markup.beginEditingOuterHTML(this.selection.nodeFront); } }, /** * Paste the contents of the clipboard into the selected Node's outer HTML. */ pasteOuterHTML: function() { let content = this._getClipboardContentForPaste(); if (!content) { return promise.reject("No clipboard content for paste"); } let node = this.selection.nodeFront; return this.markup.getNodeOuterHTML(node).then(oldContent => { this.markup.updateNodeOuterHTML(node, content, oldContent); }); }, /** * Paste the contents of the clipboard into the selected Node's inner HTML. */ pasteInnerHTML: function() { let content = this._getClipboardContentForPaste(); if (!content) { return promise.reject("No clipboard content for paste"); } let node = this.selection.nodeFront; return this.markup.getNodeInnerHTML(node).then(oldContent => { this.markup.updateNodeInnerHTML(node, content, oldContent); }); }, /** * Paste the contents of the clipboard as adjacent HTML to the selected Node. * @param position * The position as specified for Element.insertAdjacentHTML * (i.e. "beforeBegin", "afterBegin", "beforeEnd", "afterEnd"). */ pasteAdjacentHTML: function(position) { let content = this._getClipboardContentForPaste(); if (!content) { return promise.reject("No clipboard content for paste"); } let node = this.selection.nodeFront; return this.markup.insertAdjacentHTMLToNode(node, position, content); }, /** * Copy the innerHTML of the selected Node to the clipboard. */ copyInnerHTML: function() { if (!this.selection.isNode()) { return; } this._copyLongString(this.walker.innerHTML(this.selection.nodeFront)); }, /** * Copy the outerHTML of the selected Node to the clipboard. */ copyOuterHTML: function() { if (!this.selection.isNode()) { return; } let node = this.selection.nodeFront; switch (node.nodeType) { case nodeConstants.ELEMENT_NODE : this._copyLongString(this.walker.outerHTML(node)); break; case nodeConstants.COMMENT_NODE : this._getLongString(node.getNodeValue()).then(comment => { clipboardHelper.copyString(""); }); break; case nodeConstants.DOCUMENT_TYPE_NODE : clipboardHelper.copyString(node.doctypeString); break; } }, /** * Copy the data-uri for the currently selected image in the clipboard. */ copyImageDataUri: function() { let container = this.markup.getContainer(this.selection.nodeFront); if (container && container.isPreviewable()) { container.copyImageDataUri(); } }, /** * Copy the content of a longString (via a promise resolving a * LongStringActor) to the clipboard * @param {Promise} longStringActorPromise * promise expected to resolve a LongStringActor instance * @return {Promise} promise resolving (with no argument) when the * string is sent to the clipboard */ _copyLongString: function(longStringActorPromise) { return this._getLongString(longStringActorPromise).then(string => { clipboardHelper.copyString(string); }).catch(console.error); }, /** * Retrieve the content of a longString (via a promise resolving a LongStringActor) * @param {Promise} longStringActorPromise * promise expected to resolve a LongStringActor instance * @return {Promise} promise resolving with the retrieved string as argument */ _getLongString: function(longStringActorPromise) { return longStringActorPromise.then(longStringActor => { return longStringActor.string().then(string => { longStringActor.release().catch(console.error); return string; }); }).catch(console.error); }, /** * Copy a unique selector of the selected Node to the clipboard. */ copyUniqueSelector: function() { if (!this.selection.isNode()) { return; } this.telemetry.toolOpened("copyuniquecssselector"); this.selection.nodeFront.getUniqueSelector().then(selector => { clipboardHelper.copyString(selector); }).catch(console.error); }, /** * Copy the full CSS Path of the selected Node to the clipboard. */ copyCssPath: function() { if (!this.selection.isNode()) { return; } this.telemetry.toolOpened("copyfullcssselector"); this.selection.nodeFront.getCssPath().then(path => { clipboardHelper.copyString(path); }).catch(console.error); }, /** * Copy the XPath of the selected Node to the clipboard. */ copyXPath: function() { if (!this.selection.isNode()) { return; } this.telemetry.toolOpened("copyxpath"); this.selection.nodeFront.getXPath().then(path => { clipboardHelper.copyString(path); }).catch(console.error); }, /** * Initiate gcli screenshot command on selected node. */ async screenshotNode() { const command = Services.prefs.getBoolPref("devtools.screenshot.clipboard.enabled") ? "screenshot --file --clipboard --selector" : "screenshot --file --selector"; // Bug 1332936 - it's possible to call `screenshotNode` while the BoxModel highlighter // is still visible, therefore showing it in the picture. // To avoid that, we have to hide it before taking the screenshot. The `hideBoxModel` // will do that, calling `hide` for the highlighter only if previously shown. await this.highlighter.hideBoxModel(); // Bug 1180314 - CssSelector might contain white space so need to make sure it is // passed to screenshot as a single parameter. More work *might* be needed if // CssSelector could contain escaped single- or double-quotes, backslashes, etc. CommandUtils.executeOnTarget(this._target, `${command} '${this.selectionCssSelector}'`); }, /** * Scroll the node into view. */ scrollNodeIntoView: function() { if (!this.selection.isNode()) { return; } this.selection.nodeFront.scrollIntoView(); }, /** * Duplicate the selected node */ duplicateNode: function() { let selection = this.selection; if (!selection.isElementNode() || selection.isRoot() || selection.isAnonymousNode() || selection.isPseudoElementNode()) { return; } this.walker.duplicateNode(selection.nodeFront).catch(console.error); }, /** * Delete the selected node. */ deleteNode: function() { if (!this.selection.isNode() || this.selection.isRoot()) { return; } // If the markup panel is active, use the markup panel to delete // the node, making this an undoable action. if (this.markup) { this.markup.deleteNode(this.selection.nodeFront); } else { // remove the node from content this.walker.removeNode(this.selection.nodeFront); } }, /** * Add attribute to node. * Used for node context menu and shouldn't be called directly. */ onAddAttribute: function() { let container = this.markup.getContainer(this.selection.nodeFront); container.addAttribute(); }, /** * Copy attribute value for node. * Used for node context menu and shouldn't be called directly. */ onCopyAttributeValue: function() { clipboardHelper.copyString(this.nodeMenuTriggerInfo.value); }, /** * Edit attribute for node. * Used for node context menu and shouldn't be called directly. */ onEditAttribute: function() { let container = this.markup.getContainer(this.selection.nodeFront); container.editAttribute(this.nodeMenuTriggerInfo.name); }, /** * Remove attribute from node. * Used for node context menu and shouldn't be called directly. */ onRemoveAttribute: function() { let container = this.markup.getContainer(this.selection.nodeFront); container.removeAttribute(this.nodeMenuTriggerInfo.name); }, expandNode: function() { this.markup.expandAll(this.selection.nodeFront); }, collapseAll: function() { this.markup.collapseAll(this.selection.nodeFront); }, /** * This method is here for the benefit of the node-menu-link-follow menu item * in the inspector contextual-menu. */ onFollowLink: function() { let type = this.contextMenuTarget.dataset.type; let link = this.contextMenuTarget.dataset.link; this.followAttributeLink(type, link); }, /** * Given a type and link found in a node's attribute in the markup-view, * attempt to follow that link (which may result in opening a new tab, the * style editor or debugger). */ followAttributeLink: function(type, link) { if (!type || !link) { return; } if (type === "uri" || type === "cssresource" || type === "jsresource") { // Open link in a new tab. this.inspector.resolveRelativeURL( link, this.selection.nodeFront).then(url => { if (type === "uri") { let browserWin = this.target.tab.ownerDocument.defaultView; browserWin.openWebLinkIn(url, "tab"); } else if (type === "cssresource") { return this.toolbox.viewSourceInStyleEditor(url); } else if (type === "jsresource") { return this.toolbox.viewSourceInDebugger(url); } return null; }).catch(console.error); } else if (type == "idref") { // Select the node in the same document. this.walker.document(this.selection.nodeFront).then(doc => { return this.walker.querySelector(doc, "#" + CSS.escape(link)).then(node => { if (!node) { this.emit("idref-attribute-link-failed"); return; } this.selection.setNodeFront(node); }); }).catch(console.error); } }, /** * This method is here for the benefit of the node-menu-link-copy menu item * in the inspector contextual-menu. */ onCopyLink: function() { let link = this.contextMenuTarget.dataset.link; this.copyAttributeLink(link); }, /** * This method is here for the benefit of copying links. */ copyAttributeLink: function(link) { this.inspector.resolveRelativeURL(link, this.selection.nodeFront).then(url => { clipboardHelper.copyString(url); }, console.error); }, /** * Returns an object containing the shared handler functions used in the box * model and grid React components. */ getCommonComponentProps() { return { setSelectedNode: this.selection.setNodeFront, onShowBoxModelHighlighterForNode: this.onShowBoxModelHighlighterForNode, }; }, /** * Shows the box-model highlighter on the element corresponding to the provided * NodeFront. * * @param {NodeFront} nodeFront * The node to highlight. * @param {Object} options * Options passed to the highlighter actor. */ onShowBoxModelHighlighterForNode(nodeFront, options) { let toolbox = this.toolbox; toolbox.highlighterUtils.highlightNodeFront(nodeFront, options); }, /** * Returns a value indicating whether a node can be deleted. * * @param {NodeFront} nodeFront * The node to test for deletion */ isDeletable(nodeFront) { return !(nodeFront.isDocumentElement || nodeFront.nodeType == nodeConstants.DOCUMENT_TYPE_NODE || nodeFront.isAnonymous); }, async inspectNodeActor(nodeActor, inspectFromAnnotation) { const nodeFront = await this.walker.getNodeActorFromObjectActor(nodeActor); if (!nodeFront) { console.error("The object cannot be linked to the inspector, the " + "corresponding nodeFront could not be found."); return false; } let isAttached = await this.walker.isInDOMTree(nodeFront); if (!isAttached) { console.error("Selected DOMNode is not attached to the document tree."); return false; } await this.selection.setNodeFront(nodeFront, { reason: inspectFromAnnotation }); return true; }, }; exports.Inspector = Inspector;