gecko/browser/devtools/inspector/inspector-panel.js

849 lines
26 KiB
JavaScript

/* -*- Mode: Javascript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 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/. */
const {Cc, Ci, Cu, Cr} = require("chrome");
Cu.import("resource://gre/modules/Services.jsm");
let promise = require("devtools/toolkit/deprecated-sync-thenables");
let EventEmitter = require("devtools/toolkit/event-emitter");
let {CssLogic} = require("devtools/styleinspector/css-logic");
loader.lazyGetter(this, "MarkupView", () => require("devtools/markupview/markup-view").MarkupView);
loader.lazyGetter(this, "HTMLBreadcrumbs", () => require("devtools/inspector/breadcrumbs").HTMLBreadcrumbs);
loader.lazyGetter(this, "ToolSidebar", () => require("devtools/framework/sidebar").ToolSidebar);
loader.lazyGetter(this, "SelectorSearch", () => require("devtools/inspector/selector-search").SelectorSearch);
const LAYOUT_CHANGE_TIMER = 250;
/**
* 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 layout view).
*
* 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
* - layout-change
* Fired when the layout of the inspector changes
* - breadcrumbs-updated
* Fired when the breadcrumb widget updates to a new node
* - layoutview-updated
* Fired when the layoutview (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
* - rule-view-refreshed
* Fired when the rule view updates to a new node
*/
function InspectorPanel(iframeWindow, toolbox) {
this._toolbox = toolbox;
this._target = toolbox._target;
this.panelDoc = iframeWindow.document;
this.panelWin = iframeWindow;
this.panelWin.inspector = this;
this._inspector = null;
this._onBeforeNavigate = this._onBeforeNavigate.bind(this);
this._target.on("will-navigate", this._onBeforeNavigate);
EventEmitter.decorate(this);
}
exports.InspectorPanel = InspectorPanel;
InspectorPanel.prototype = {
/**
* open is effectively an asynchronous constructor
*/
open: function InspectorPanel_open() {
return this.target.makeRemote().then(() => {
return this._getPageStyle();
}).then(() => {
return this._getDefaultNodeForSelection();
}).then(defaultSelection => {
return this._deferredOpen(defaultSelection);
}).then(null, console.error);
},
get toolbox() {
return this._toolbox;
},
get inspector() {
return this._toolbox.inspector;
},
get walker() {
return this._toolbox.walker;
},
get selection() {
return this._toolbox.selection;
},
get isOuterHTMLEditable() {
return this._target.client.traits.editOuterHTML;
},
get hasUrlToImageDataResolver() {
return this._target.client.traits.urlToImageDataResolver;
},
_deferredOpen: function(defaultSelection) {
let deferred = promise.defer();
this.onNewRoot = this.onNewRoot.bind(this);
this.walker.on("new-root", this.onNewRoot);
this.nodemenu = this.panelDoc.getElementById("inspector-node-popup");
this.lastNodemenuItem = this.nodemenu.lastChild;
this._setupNodeMenu = this._setupNodeMenu.bind(this);
this._resetNodeMenu = this._resetNodeMenu.bind(this);
this.nodemenu.addEventListener("popupshowing", this._setupNodeMenu, true);
this.nodemenu.addEventListener("popuphiding", this._resetNodeMenu, true);
this.onNewSelection = this.onNewSelection.bind(this);
this.selection.on("new-node-front", this.onNewSelection);
this.onBeforeNewSelection = this.onBeforeNewSelection.bind(this);
this.selection.on("before-new-node-front", this.onBeforeNewSelection);
this.onDetached = this.onDetached.bind(this);
this.selection.on("detached-front", this.onDetached);
this.breadcrumbs = new HTMLBreadcrumbs(this);
if (this.target.isLocalTab) {
this.browser = this.target.tab.linkedBrowser;
this.scheduleLayoutChange = this.scheduleLayoutChange.bind(this);
this.browser.addEventListener("resize", this.scheduleLayoutChange, true);
// Show a warning when the debugger is paused.
// We show the warning only when the inspector
// is selected.
this.updateDebuggerPausedWarning = function() {
let notificationBox = this._toolbox.getNotificationBox();
let notification = notificationBox.getNotificationWithValue("inspector-script-paused");
if (!notification && this._toolbox.currentToolId == "inspector" &&
this.target.isThreadPaused) {
let message = this.strings.GetStringFromName("debuggerPausedWarning.message");
notificationBox.appendNotification(message,
"inspector-script-paused", "", notificationBox.PRIORITY_WARNING_HIGH);
}
if (notification && this._toolbox.currentToolId != "inspector") {
notificationBox.removeNotification(notification);
}
if (notification && !this.target.isThreadPaused) {
notificationBox.removeNotification(notification);
}
}.bind(this);
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.once("markuploaded", function() {
this.isReady = true;
// All the components are initialized. Let's select a node.
this.selection.setNodeFront(defaultSelection, "inspector-open");
this.markup.expandNode(this.selection.nodeFront);
this.emit("ready");
deferred.resolve(this);
}.bind(this));
this.setupSearchBox();
this.setupSidebar();
return deferred.promise;
},
_onBeforeNavigate: function() {
this._defaultNode = null;
this.selection.setNodeFront(null);
this._destroyMarkup();
this.isDirty = false;
},
_getPageStyle: function() {
return this._toolbox.inspector.getPageStyle().then(pageStyle => {
this.pageStyle = pageStyle;
});
},
/**
* 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;
// If available, set either the previously selected node or the body
// as default selected, else set documentElement
return walker.getRootNode().then(aRootNode => {
rootNode = aRootNode;
return walker.querySelector(rootNode, this.selectionCssSelector);
}).then(front => {
if (front) {
return front;
}
return walker.querySelector(rootNode, "body");
}).then(front => {
if (front) {
return front;
}
return this.walker.documentElement(this.walker.rootNode);
}).then(node => {
if (walker !== this.walker) {
promise.reject(null);
}
this._defaultNode = node;
return node;
});
},
/**
* Target getter.
*/
get target() {
return this._target;
},
/**
* Target setter.
*/
set target(value) {
this._target = value;
},
/**
* Expose gViewSourceUtils so that other tools can make use of them.
*/
get viewSourceUtils() {
return this.panelWin.gViewSourceUtils;
},
/**
* Indicate that a tool has modified the state of the page. Used to
* decide whether to show the "are you sure you want to navigate"
* notification.
*/
markDirty: function InspectorPanel_markDirty() {
this.isDirty = true;
},
/**
* Hooks the searchbar to show result and auto completion suggestions.
*/
setupSearchBox: function InspectorPanel_setupSearchBox() {
// Initiate the selectors search object.
if (this.searchSuggestions) {
this.searchSuggestions.destroy();
this.searchSuggestions = null;
}
this.searchBox = this.panelDoc.getElementById("inspector-searchbox");
this.searchSuggestions = new SelectorSearch(this, this.searchBox);
},
/**
* Build the sidebar.
*/
setupSidebar: function InspectorPanel_setupSidebar() {
let tabbox = this.panelDoc.querySelector("#inspector-sidebar");
this.sidebar = new ToolSidebar(tabbox, this, "inspector");
let defaultTab = Services.prefs.getCharPref("devtools.inspector.activeSidebar");
this._setDefaultSidebar = function(event, toolId) {
Services.prefs.setCharPref("devtools.inspector.activeSidebar", toolId);
}.bind(this);
this.sidebar.on("select", this._setDefaultSidebar);
this.sidebar.addTab("ruleview",
"chrome://browser/content/devtools/cssruleview.xhtml",
"ruleview" == defaultTab);
this.sidebar.addTab("computedview",
"chrome://browser/content/devtools/computedview.xhtml",
"computedview" == defaultTab);
if (Services.prefs.getBoolPref("devtools.fontinspector.enabled") && !this.target.isRemote) {
this.sidebar.addTab("fontinspector",
"chrome://browser/content/devtools/fontinspector/font-inspector.xhtml",
"fontinspector" == defaultTab);
}
this.sidebar.addTab("layoutview",
"chrome://browser/content/devtools/layoutview/view.xhtml",
"layoutview" == defaultTab);
let ruleViewTab = this.sidebar.getTab("ruleview");
this.sidebar.show();
},
/**
* Reset the inspector on new root mutation.
*/
onNewRoot: function InspectorPanel_onNewRoot() {
this._defaultNode = null;
this.selection.setNodeFront(null);
this._destroyMarkup();
this.isDirty = false;
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, "navigateaway");
this._initMarkup();
this.once("markuploaded", () => {
if (!this.markup) {
return;
}
this.markup.expandNode(this.selection.nodeFront);
this.setupSearchBox();
this.emit("new-root");
});
};
this._pendingSelection = onNodeSelected;
this._getDefaultNodeForSelection().then(onNodeSelected);
},
_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) {
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;
} else {
return null;
}
},
/**
* When a new node is selected.
*/
onNewSelection: function InspectorPanel_onNewSelection(event, value, reason) {
if (reason === "selection-destroy") {
return;
}
this.cancelLayoutChange();
// Wait for all the known tools to finish updating and then let the
// client know.
let selection = this.selection.nodeFront;
// 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 (reason !== "navigateaway" &&
this.selection.node &&
this.selection.isElementNode()) {
this.selectionCssSelector = CssLogic.findCssSelector(this.selection.node);
}
let selfUpdate = this.updating("inspector-panel");
Services.tm.mainThread.dispatch(() => {
try {
selfUpdate(selection);
} catch(ex) {
console.error(ex);
}
}, Ci.nsIThread.DISPATCH_NORMAL);
},
/**
* 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.
var self = this;
this._updateProgress = {
node: this.selection.nodeFront,
outstanding: new Set(),
checkDone: function() {
if (this !== self._updateProgress) {
return;
}
if (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 new node is selected, before the selection has changed.
*/
onBeforeNewSelection: function InspectorPanel_onBeforeNewSelection(event,
node) {
if (this.breadcrumbs.indexOf(node) == -1) {
// only clear locks if we'd have to update breadcrumbs
this.clearPseudoClasses();
}
},
/**
* 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 InspectorPanel_onDetached(event, parentNode) {
this.cancelLayoutChange();
this.breadcrumbs.cutAfter(this.breadcrumbs.indexOf(parentNode));
this.selection.setNodeFront(parentNode ? parentNode : this._defaultNode, "detached");
},
/**
* Destroy the inspector.
*/
destroy: function InspectorPanel__destroy() {
if (this._panelDestroyer) {
return this._panelDestroyer;
}
if (this.walker) {
this.walker.off("new-root", this.onNewRoot);
this.pageStyle = null;
}
this.cancelUpdate();
this.cancelLayoutChange();
if (this.browser) {
this.browser.removeEventListener("resize", this.scheduleLayoutChange, true);
this.browser = null;
}
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);
this.sidebar.off("select", this._setDefaultSidebar);
this.sidebar.destroy();
this.sidebar = null;
this.nodemenu.removeEventListener("popupshowing", this._setupNodeMenu, true);
this.nodemenu.removeEventListener("popuphiding", this._resetNodeMenu, true);
this.breadcrumbs.destroy();
this.searchSuggestions.destroy();
this.searchBox = null;
this.selection.off("new-node-front", this.onNewSelection);
this.selection.off("before-new-node", this.onBeforeNewSelection);
this.selection.off("before-new-node-front", this.onBeforeNewSelection);
this.selection.off("detached-front", this.onDetached);
this._panelDestroyer = this._destroyMarkup();
this.panelWin.inspector = null;
this.target = null;
this.panelDoc = null;
this.panelWin = null;
this.breadcrumbs = null;
this.searchSuggestions = null;
this.lastNodemenuItem = null;
this.nodemenu = null;
this._toolbox = null;
return this._panelDestroyer;
},
/**
* Show the node menu.
*/
showNodeMenu: function InspectorPanel_showNodeMenu(aButton, aPosition, aExtraItems) {
if (aExtraItems) {
for (let item of aExtraItems) {
this.nodemenu.appendChild(item);
}
}
this.nodemenu.openPopup(aButton, aPosition, 0, 0, true, false);
},
hideNodeMenu: function InspectorPanel_hideNodeMenu() {
this.nodemenu.hidePopup();
},
/**
* Disable the delete item if needed. Update the pseudo classes.
*/
_setupNodeMenu: function InspectorPanel_setupNodeMenu() {
let isSelectionElement = this.selection.isElementNode();
// Set the pseudo classes
for (let name of ["hover", "active", "focus"]) {
let menu = this.panelDoc.getElementById("node-menu-pseudo-" + name);
if (isSelectionElement) {
let checked = this.selection.nodeFront.hasPseudoClassLock(":" + name);
menu.setAttribute("checked", checked);
menu.removeAttribute("disabled");
} else {
menu.setAttribute("disabled", "true");
}
}
// Disable delete item if needed
let deleteNode = this.panelDoc.getElementById("node-menu-delete");
if (this.selection.isRoot() || this.selection.isDocumentTypeNode()) {
deleteNode.setAttribute("disabled", "true");
} else {
deleteNode.removeAttribute("disabled");
}
// Disable / enable "Copy Unique Selector", "Copy inner HTML" &
// "Copy outer HTML" as appropriate
let unique = this.panelDoc.getElementById("node-menu-copyuniqueselector");
let copyInnerHTML = this.panelDoc.getElementById("node-menu-copyinner");
let copyOuterHTML = this.panelDoc.getElementById("node-menu-copyouter");
if (isSelectionElement) {
unique.removeAttribute("disabled");
copyInnerHTML.removeAttribute("disabled");
copyOuterHTML.removeAttribute("disabled");
} else {
unique.setAttribute("disabled", "true");
copyInnerHTML.setAttribute("disabled", "true");
copyOuterHTML.setAttribute("disabled", "true");
}
// Enable the "edit HTML" item if the selection is an element and the root
// actor has the appropriate trait (isOuterHTMLEditable)
let editHTML = this.panelDoc.getElementById("node-menu-edithtml");
if (this.isOuterHTMLEditable && isSelectionElement) {
editHTML.removeAttribute("disabled");
} else {
editHTML.setAttribute("disabled", "true");
}
// Enable the "copy image data-uri" item if the selection is previewable
// which essentially checks if it's an image or canvas tag
let copyImageData = this.panelDoc.getElementById("node-menu-copyimagedatauri");
let markupContainer = this.markup.getContainer(this.selection.nodeFront);
if (markupContainer && markupContainer.isPreviewable()) {
copyImageData.removeAttribute("disabled");
} else {
copyImageData.setAttribute("disabled", "true");
}
},
_resetNodeMenu: function InspectorPanel_resetNodeMenu() {
// Remove any extra items
while (this.lastNodemenuItem.nextSibling) {
let toDelete = this.lastNodemenuItem.nextSibling;
toDelete.parentNode.removeChild(toDelete);
}
},
_initMarkup: function InspectorPanel_initMarkup() {
let doc = this.panelDoc;
this._markupBox = doc.getElementById("markup-box");
// create tool iframe
this._markupFrame = doc.createElement("iframe");
this._markupFrame.setAttribute("flex", "1");
this._markupFrame.setAttribute("tooltip", "aHTMLTooltip");
this._markupFrame.setAttribute("context", "inspector-node-popup");
// This is needed to enable tooltips inside the iframe document.
this._boundMarkupFrameLoad = this._onMarkupFrameLoad.bind(this);
this._markupFrame.addEventListener("load", this._boundMarkupFrameLoad, true);
this._markupBox.setAttribute("collapsed", true);
this._markupBox.appendChild(this._markupFrame);
this._markupFrame.setAttribute("src", "chrome://browser/content/devtools/markup-view.xhtml");
},
_onMarkupFrameLoad: function InspectorPanel__onMarkupFrameLoad() {
this._markupFrame.removeEventListener("load", this._boundMarkupFrameLoad, true);
delete this._boundMarkupFrameLoad;
this._markupFrame.contentWindow.focus();
this._markupBox.removeAttribute("collapsed");
let controllerWindow = this._toolbox.doc.defaultView;
this.markup = new MarkupView(this, this._markupFrame, controllerWindow);
this.emit("markuploaded");
},
_destroyMarkup: function InspectorPanel__destroyMarkup() {
let destroyPromise;
if (this._boundMarkupFrameLoad) {
this._markupFrame.removeEventListener("load", this._boundMarkupFrameLoad, true);
this._boundMarkupFrameLoad = null;
}
if (this.markup) {
destroyPromise = this.markup.destroy();
this.markup = null;
} else {
destroyPromise = promise.resolve();
}
if (this._markupFrame) {
this._markupFrame.parentNode.removeChild(this._markupFrame);
this._markupFrame = null;
}
this._markupBox = null;
return destroyPromise;
},
/**
* Toggle a pseudo class.
*/
togglePseudoClass: function InspectorPanel_togglePseudoClass(aPseudo) {
if (this.selection.isElementNode()) {
let node = this.selection.nodeFront;
if (node.hasPseudoClassLock(aPseudo)) {
return this.walker.removePseudoClassLock(node, aPseudo, {parents: true});
}
let hierarchical = aPseudo == ":hover" || aPseudo == ":active";
return this.walker.addPseudoClassLock(node, aPseudo, {parents: hierarchical});
}
},
/**
* Clear any pseudo-class locks applied to the current hierarchy.
*/
clearPseudoClasses: function InspectorPanel_clearPseudoClasses() {
if (!this.walker) {
return;
}
return this.walker.clearPseudoClassLocks().then(null, console.error);
},
/**
* Edit the outerHTML of the selected Node.
*/
editHTML: function InspectorPanel_editHTML()
{
if (!this.selection.isNode()) {
return;
}
if (this.markup) {
this.markup.beginEditingOuterHTML(this.selection.nodeFront);
}
},
/**
* Copy the innerHTML of the selected Node to the clipboard.
*/
copyInnerHTML: function InspectorPanel_copyInnerHTML()
{
if (!this.selection.isNode()) {
return;
}
this._copyLongStr(this.walker.innerHTML(this.selection.nodeFront));
},
/**
* Copy the outerHTML of the selected Node to the clipboard.
*/
copyOuterHTML: function InspectorPanel_copyOuterHTML()
{
if (!this.selection.isNode()) {
return;
}
this._copyLongStr(this.walker.outerHTML(this.selection.nodeFront));
},
/**
* Copy the data-uri for the currently selected image in the clipboard.
*/
copyImageDataUri: function InspectorPanel_copyImageDataUri()
{
let container = this.markup.getContainer(this.selection.nodeFront);
if (container && container.isPreviewable()) {
container.copyImageDataUri();
}
},
_copyLongStr: function InspectorPanel_copyLongStr(promise)
{
return promise.then(longstr => {
return longstr.string().then(toCopy => {
longstr.release().then(null, console.error);
clipboardHelper.copyString(toCopy);
});
}).then(null, console.error);
},
/**
* Copy a unique selector of the selected Node to the clipboard.
*/
copyUniqueSelector: function InspectorPanel_copyUniqueSelector()
{
if (!this.selection.isNode()) {
return;
}
let toCopy = CssLogic.findCssSelector(this.selection.node);
if (toCopy) {
clipboardHelper.copyString(toCopy);
}
},
/**
* Delete the selected node.
*/
deleteNode: function IUI_deleteNode() {
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);
}
},
/**
* Trigger a high-priority layout change for things that need to be
* updated immediately
*/
immediateLayoutChange: function Inspector_immediateLayoutChange()
{
this.emit("layout-change");
},
/**
* Schedule a low-priority change event for things like paint
* and resize.
*/
scheduleLayoutChange: function Inspector_scheduleLayoutChange(event)
{
// Filter out non browser window resize events (i.e. triggered by iframes)
if (this.browser.contentWindow === event.target) {
if (this._timer) {
return null;
}
this._timer = this.panelWin.setTimeout(function() {
this.emit("layout-change");
this._timer = null;
}.bind(this), LAYOUT_CHANGE_TIMER);
}
},
/**
* Cancel a pending low-priority change event if any is
* scheduled.
*/
cancelLayoutChange: function Inspector_cancelLayoutChange()
{
if (this._timer) {
this.panelWin.clearTimeout(this._timer);
delete this._timer;
}
}
};
/////////////////////////////////////////////////////////////////////////
//// Initializers
loader.lazyGetter(InspectorPanel.prototype, "strings",
function () {
return Services.strings.createBundle(
"chrome://browser/locale/devtools/inspector.properties");
});
loader.lazyGetter(this, "clipboardHelper", function() {
return Cc["@mozilla.org/widget/clipboardhelper;1"].
getService(Ci.nsIClipboardHelper);
});
loader.lazyGetter(this, "DOMUtils", function () {
return Cc["@mozilla.org/inspector/dom-utils;1"].getService(Ci.inIDOMUtils);
});