Mypal/devtools/server/actors/inspector.js

3199 lines
98 KiB
JavaScript

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
/**
* Here's the server side of the remote inspector.
*
* The WalkerActor is the client's view of the debuggee's DOM. It's gives
* the client a tree of NodeActor objects.
*
* The walker presents the DOM tree mostly unmodified from the source DOM
* tree, but with a few key differences:
*
* - Empty text nodes are ignored. This is pretty typical of developer
* tools, but maybe we should reconsider that on the server side.
* - iframes with documents loaded have the loaded document as the child,
* the walker provides one big tree for the whole document tree.
*
* There are a few ways to get references to NodeActors:
*
* - When you first get a WalkerActor reference, it comes with a free
* reference to the root document's node.
* - Given a node, you can ask for children, siblings, and parents.
* - You can issue querySelector and querySelectorAll requests to find
* other elements.
* - Requests that return arbitrary nodes from the tree (like querySelector
* and querySelectorAll) will also return any nodes the client hasn't
* seen in order to have a complete set of parents.
*
* Once you have a NodeFront, you should be able to answer a few questions
* without further round trips, like the node's name, namespace/tagName,
* attributes, etc. Other questions (like a text node's full nodeValue)
* might require another round trip.
*
* The protocol guarantees that the client will always know the parent of
* any node that is returned by the server. This means that some requests
* (like querySelector) will include the extra nodes needed to satisfy this
* requirement. The client keeps track of this parent relationship, so the
* node fronts form a tree that is a subset of the actual DOM tree.
*
*
* We maintain this guarantee to support the ability to release subtrees on
* the client - when a node is disconnected from the DOM tree we want to be
* able to free the client objects for all the children nodes.
*
* So to be able to answer "all the children of a given node that we have
* seen on the client side", we guarantee that every time we've seen a node,
* we connect it up through its parents.
*/
const {Cc, Ci, Cu} = require("chrome");
const Services = require("Services");
const protocol = require("devtools/shared/protocol");
const {LayoutActor} = require("devtools/server/actors/layout");
const {LongStringActor} = require("devtools/server/actors/string");
const promise = require("promise");
const {Task} = require("devtools/shared/task");
const events = require("sdk/event/core");
const {WalkerSearch} = require("devtools/server/actors/utils/walker-search");
const {PageStyleActor, getFontPreviewData} = require("devtools/server/actors/styles");
const {
HighlighterActor,
CustomHighlighterActor,
isTypeRegistered,
HighlighterEnvironment
} = require("devtools/server/actors/highlighters");
const {EyeDropper} = require("devtools/server/actors/highlighters/eye-dropper");
const {
isAnonymous,
isNativeAnonymous,
isXBLAnonymous,
isShadowAnonymous,
getFrameElement
} = require("devtools/shared/layout/utils");
const {getLayoutChangesObserver, releaseLayoutChangesObserver} = require("devtools/server/actors/reflow");
const nodeFilterConstants = require("devtools/shared/dom-node-filter-constants");
const {EventParsers} = require("devtools/server/event-parsers");
const {nodeSpec, nodeListSpec, walkerSpec, inspectorSpec} = require("devtools/shared/specs/inspector");
const FONT_FAMILY_PREVIEW_TEXT = "The quick brown fox jumps over the lazy dog";
const FONT_FAMILY_PREVIEW_TEXT_SIZE = 20;
const PSEUDO_CLASSES = [":hover", ":active", ":focus"];
const HIDDEN_CLASS = "__fx-devtools-hide-shortcut__";
const XHTML_NS = "http://www.w3.org/1999/xhtml";
const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
const IMAGE_FETCHING_TIMEOUT = 500;
const RX_FUNC_NAME =
/((var|const|let)\s+)?([\w$.]+\s*[:=]\s*)*(function)?\s*\*?\s*([\w$]+)?\s*$/;
// The possible completions to a ':' with added score to give certain values
// some preference.
const PSEUDO_SELECTORS = [
[":active", 1],
[":hover", 1],
[":focus", 1],
[":visited", 0],
[":link", 0],
[":first-letter", 0],
[":first-child", 2],
[":before", 2],
[":after", 2],
[":lang(", 0],
[":not(", 3],
[":first-of-type", 0],
[":last-of-type", 0],
[":only-of-type", 0],
[":only-child", 2],
[":nth-child(", 3],
[":nth-last-child(", 0],
[":nth-of-type(", 0],
[":nth-last-of-type(", 0],
[":last-child", 2],
[":root", 0],
[":empty", 0],
[":target", 0],
[":enabled", 0],
[":disabled", 0],
[":checked", 1],
["::selection", 0]
];
var HELPER_SHEET = `
.__fx-devtools-hide-shortcut__ {
visibility: hidden !important;
}
:-moz-devtools-highlighted {
outline: 2px dashed #F06!important;
outline-offset: -2px !important;
}
`;
const flags = require("devtools/shared/flags");
loader.lazyRequireGetter(this, "DevToolsUtils",
"devtools/shared/DevToolsUtils");
loader.lazyRequireGetter(this, "AsyncUtils", "devtools/shared/async-utils");
loader.lazyGetter(this, "DOMParser", function () {
return Cc["@mozilla.org/xmlextras/domparser;1"]
.createInstance(Ci.nsIDOMParser);
});
loader.lazyGetter(this, "eventListenerService", function () {
return Cc["@mozilla.org/eventlistenerservice;1"]
.getService(Ci.nsIEventListenerService);
});
loader.lazyGetter(this, "CssLogic", () => require("devtools/server/css-logic").CssLogic);
/**
* We only send nodeValue up to a certain size by default. This stuff
* controls that size.
*/
exports.DEFAULT_VALUE_SUMMARY_LENGTH = 50;
var gValueSummaryLength = exports.DEFAULT_VALUE_SUMMARY_LENGTH;
exports.getValueSummaryLength = function () {
return gValueSummaryLength;
};
exports.setValueSummaryLength = function (val) {
gValueSummaryLength = val;
};
// When the user selects a node to inspect in e10s, the parent process
// has a CPOW that wraps the node being inspected. It uses the
// message manager to send this node to the child, which stores the
// node in gInspectingNode. Then a findInspectingNode request is sent
// over the remote debugging protocol, and gInspectingNode is returned
// to the parent as a NodeFront.
var gInspectingNode = null;
// We expect this function to be called from the child.js frame script
// when it receives the node to be inspected over the message manager.
exports.setInspectingNode = function (val) {
gInspectingNode = val;
};
/**
* Returns the properly cased version of the node's tag name, which can be
* used when displaying said name in the UI.
*
* @param {Node} rawNode
* Node for which we want the display name
* @return {String}
* Properly cased version of the node tag name
*/
const getNodeDisplayName = function (rawNode) {
if (rawNode.nodeName && !rawNode.localName) {
// The localName & prefix APIs have been moved from the Node interface to the Element
// interface. Use Node.nodeName as a fallback.
return rawNode.nodeName;
}
return (rawNode.prefix ? rawNode.prefix + ":" : "") + rawNode.localName;
};
exports.getNodeDisplayName = getNodeDisplayName;
/**
* Server side of the node actor.
*/
var NodeActor = exports.NodeActor = protocol.ActorClassWithSpec(nodeSpec, {
initialize: function (walker, node) {
protocol.Actor.prototype.initialize.call(this, null);
this.walker = walker;
this.rawNode = node;
this._eventParsers = new EventParsers().parsers;
// Storing the original display of the node, to track changes when reflows
// occur
this.wasDisplayed = this.isDisplayed;
},
toString: function () {
return "[NodeActor " + this.actorID + " for " +
this.rawNode.toString() + "]";
},
/**
* Instead of storing a connection object, the NodeActor gets its connection
* from its associated walker.
*/
get conn() {
return this.walker.conn;
},
isDocumentElement: function () {
return this.rawNode.ownerDocument &&
this.rawNode.ownerDocument.documentElement === this.rawNode;
},
destroy: function () {
protocol.Actor.prototype.destroy.call(this);
if (this.mutationObserver) {
if (!Cu.isDeadWrapper(this.mutationObserver)) {
this.mutationObserver.disconnect();
}
this.mutationObserver = null;
}
this.rawNode = null;
this.walker = null;
},
// Returns the JSON representation of this object over the wire.
form: function (detail) {
if (detail === "actorid") {
return this.actorID;
}
let parentNode = this.walker.parentNode(this);
let inlineTextChild = this.walker.inlineTextChild(this);
let form = {
actor: this.actorID,
baseURI: this.rawNode.baseURI,
parent: parentNode ? parentNode.actorID : undefined,
nodeType: this.rawNode.nodeType,
namespaceURI: this.rawNode.namespaceURI,
nodeName: this.rawNode.nodeName,
nodeValue: this.rawNode.nodeValue,
displayName: getNodeDisplayName(this.rawNode),
numChildren: this.numChildren,
inlineTextChild: inlineTextChild ? inlineTextChild.form() : undefined,
// doctype attributes
name: this.rawNode.name,
publicId: this.rawNode.publicId,
systemId: this.rawNode.systemId,
attrs: this.writeAttrs(),
isBeforePseudoElement: this.isBeforePseudoElement,
isAfterPseudoElement: this.isAfterPseudoElement,
isAnonymous: isAnonymous(this.rawNode),
isNativeAnonymous: isNativeAnonymous(this.rawNode),
isXBLAnonymous: isXBLAnonymous(this.rawNode),
isShadowAnonymous: isShadowAnonymous(this.rawNode),
pseudoClassLocks: this.writePseudoClassLocks(),
isDisplayed: this.isDisplayed,
isInHTMLDocument: this.rawNode.ownerDocument &&
this.rawNode.ownerDocument.contentType === "text/html",
hasEventListeners: this._hasEventListeners,
};
if (this.isDocumentElement()) {
form.isDocumentElement = true;
}
// Add an extra API for custom properties added by other
// modules/extensions.
form.setFormProperty = (name, value) => {
if (!form.props) {
form.props = {};
}
form.props[name] = value;
};
// Fire an event so, other modules can create its own properties
// that should be passed to the client (within the form.props field).
events.emit(NodeActor, "form", {
target: this,
data: form
});
return form;
},
/**
* Watch the given document node for mutations using the DOM observer
* API.
*/
watchDocument: function (callback) {
let node = this.rawNode;
// Create the observer on the node's actor. The node will make sure
// the observer is cleaned up when the actor is released.
let observer = new node.defaultView.MutationObserver(callback);
observer.mergeAttributeRecords = true;
observer.observe(node, {
nativeAnonymousChildList: true,
attributes: true,
characterData: true,
characterDataOldValue: true,
childList: true,
subtree: true
});
this.mutationObserver = observer;
},
get isBeforePseudoElement() {
return this.rawNode.nodeName === "_moz_generated_content_before";
},
get isAfterPseudoElement() {
return this.rawNode.nodeName === "_moz_generated_content_after";
},
// Estimate the number of children that the walker will return without making
// a call to children() if possible.
get numChildren() {
// For pseudo elements, childNodes.length returns 1, but the walker
// will return 0.
if (this.isBeforePseudoElement || this.isAfterPseudoElement) {
return 0;
}
let rawNode = this.rawNode;
let numChildren = rawNode.childNodes.length;
let hasAnonChildren = rawNode.nodeType === Ci.nsIDOMNode.ELEMENT_NODE &&
rawNode.ownerDocument.getAnonymousNodes(rawNode);
let hasContentDocument = rawNode.contentDocument;
let hasSVGDocument = rawNode.getSVGDocument && rawNode.getSVGDocument();
if (numChildren === 0 && (hasContentDocument || hasSVGDocument)) {
// This might be an iframe with virtual children.
numChildren = 1;
}
// Normal counting misses ::before/::after. Also, some anonymous children
// may ultimately be skipped, so we have to consult with the walker.
if (numChildren === 0 || hasAnonChildren) {
numChildren = this.walker.children(this).nodes.length;
}
return numChildren;
},
get computedStyle() {
return CssLogic.getComputedStyle(this.rawNode);
},
/**
* Is the node's display computed style value other than "none"
*/
get isDisplayed() {
// Consider all non-element nodes as displayed.
if (isNodeDead(this) ||
this.rawNode.nodeType !== Ci.nsIDOMNode.ELEMENT_NODE ||
this.isAfterPseudoElement ||
this.isBeforePseudoElement) {
return true;
}
let style = this.computedStyle;
if (!style) {
return true;
}
return style.display !== "none";
},
/**
* Are there event listeners that are listening on this node? This method
* uses all parsers registered via event-parsers.js.registerEventParser() to
* check if there are any event listeners.
*/
get _hasEventListeners() {
let parsers = this._eventParsers;
for (let [, {hasListeners}] of parsers) {
try {
if (hasListeners && hasListeners(this.rawNode)) {
return true;
}
} catch (e) {
// An object attached to the node looked like a listener but wasn't...
// do nothing.
}
}
return false;
},
writeAttrs: function () {
if (!this.rawNode.attributes) {
return undefined;
}
return [...this.rawNode.attributes].map(attr => {
return {namespace: attr.namespace, name: attr.name, value: attr.value };
});
},
writePseudoClassLocks: function () {
if (this.rawNode.nodeType !== Ci.nsIDOMNode.ELEMENT_NODE) {
return undefined;
}
let ret = undefined;
for (let pseudo of PSEUDO_CLASSES) {
if (DOMUtils.hasPseudoClassLock(this.rawNode, pseudo)) {
ret = ret || [];
ret.push(pseudo);
}
}
return ret;
},
/**
* Gets event listeners and adds their information to the events array.
*
* @param {Node} node
* Node for which we are to get listeners.
*/
getEventListeners: function (node) {
let parsers = this._eventParsers;
let dbg = this.parent().tabActor.makeDebugger();
let listeners = [];
for (let [, {getListeners, normalizeHandler}] of parsers) {
try {
let eventInfos = getListeners(node);
if (!eventInfos) {
continue;
}
for (let eventInfo of eventInfos) {
if (normalizeHandler) {
eventInfo.normalizeHandler = normalizeHandler;
}
this.processHandlerForEvent(node, listeners, dbg, eventInfo);
}
} catch (e) {
// An object attached to the node looked like a listener but wasn't...
// do nothing.
}
}
listeners.sort((a, b) => {
return a.type.localeCompare(b.type);
});
return listeners;
},
/**
* Process a handler
*
* @param {Node} node
* The node for which we want information.
* @param {Array} events
* The events array contains all event objects that we have gathered
* so far.
* @param {Debugger} dbg
* JSDebugger instance.
* @param {Object} eventInfo
* See event-parsers.js.registerEventParser() for a description of the
* eventInfo object.
*
* @return {Array}
* An array of objects where a typical object looks like this:
* {
* type: "click",
* handler: function() { doSomething() },
* origin: "http://www.mozilla.com",
* searchString: 'onclick="doSomething()"',
* tags: tags,
* DOM0: true,
* capturing: true,
* hide: {
* dom0: true
* }
* }
*/
processHandlerForEvent: function (node, listeners, dbg, eventInfo) {
let type = eventInfo.type || "";
let handler = eventInfo.handler;
let tags = eventInfo.tags || "";
let hide = eventInfo.hide || {};
let override = eventInfo.override || {};
let global = Cu.getGlobalForObject(handler);
let globalDO = dbg.addDebuggee(global);
let listenerDO = globalDO.makeDebuggeeValue(handler);
if (eventInfo.normalizeHandler) {
listenerDO = eventInfo.normalizeHandler(listenerDO);
}
// If the listener is an object with a 'handleEvent' method, use that.
if (listenerDO.class === "Object" || listenerDO.class === "XULElement") {
let desc;
while (!desc && listenerDO) {
desc = listenerDO.getOwnPropertyDescriptor("handleEvent");
listenerDO = listenerDO.proto;
}
if (desc && desc.value) {
listenerDO = desc.value;
}
}
if (listenerDO.isBoundFunction) {
listenerDO = listenerDO.boundTargetFunction;
}
let script = listenerDO.script;
let scriptSource = script.source.text;
let functionSource =
scriptSource.substr(script.sourceStart, script.sourceLength);
/*
The script returned is the whole script and
scriptSource.substr(script.sourceStart, script.sourceLength) returns
something like this:
() { doSomething(); }
So we need to use some regex magic to get the appropriate function info
e.g.:
() => { ... }
function doit() { ... }
doit: function() { ... }
es6func() { ... }
var|let|const foo = function () { ... }
function generator*() { ... }
*/
let scriptBeforeFunc = scriptSource.substr(0, script.sourceStart);
let matches = scriptBeforeFunc.match(RX_FUNC_NAME);
if (matches && matches.length > 0) {
functionSource = matches[0].trim() + functionSource;
}
let dom0 = false;
if (typeof node.hasAttribute !== "undefined") {
dom0 = !!node.hasAttribute("on" + type);
} else {
dom0 = !!node["on" + type];
}
let line = script.startLine;
let url = script.url;
let origin = url + (dom0 ? "" : ":" + line);
let searchString;
if (dom0) {
searchString = "on" + type + "=\"" + script.source.text + "\"";
} else {
scriptSource = " " + scriptSource;
}
let eventObj = {
type: typeof override.type !== "undefined" ? override.type : type,
handler: functionSource.trim(),
origin: typeof override.origin !== "undefined" ?
override.origin : origin,
searchString: typeof override.searchString !== "undefined" ?
override.searchString : searchString,
tags: tags,
DOM0: typeof override.dom0 !== "undefined" ? override.dom0 : dom0,
capturing: typeof override.capturing !== "undefined" ?
override.capturing : eventInfo.capturing,
hide: hide
};
listeners.push(eventObj);
dbg.removeDebuggee(globalDO);
},
/**
* Returns a LongStringActor with the node's value.
*/
getNodeValue: function () {
return new LongStringActor(this.conn, this.rawNode.nodeValue || "");
},
/**
* Set the node's value to a given string.
*/
setNodeValue: function (value) {
this.rawNode.nodeValue = value;
},
/**
* Get a unique selector string for this node.
*/
getUniqueSelector: function () {
if (Cu.isDeadWrapper(this.rawNode)) {
return "";
}
return CssLogic.findCssSelector(this.rawNode);
},
/**
* Get the full CSS path for this node.
*
* @return {String} A CSS selector with a part for the node and each of its ancestors.
*/
getCssPath: function () {
if (Cu.isDeadWrapper(this.rawNode)) {
return "";
}
return CssLogic.getCssPath(this.rawNode);
},
/**
* Scroll the selected node into view.
*/
scrollIntoView: function () {
this.rawNode.scrollIntoView(true);
},
/**
* Get the node's image data if any (for canvas and img nodes).
* Returns an imageData object with the actual data being a LongStringActor
* and a size json object.
* The image data is transmitted as a base64 encoded png data-uri.
* The method rejects if the node isn't an image or if the image is missing
*
* Accepts a maxDim request parameter to resize images that are larger. This
* is important as the resizing occurs server-side so that image-data being
* transfered in the longstring back to the client will be that much smaller
*/
getImageData: function (maxDim) {
return imageToImageData(this.rawNode, maxDim).then(imageData => {
return {
data: LongStringActor(this.conn, imageData.data),
size: imageData.size
};
});
},
/**
* Get all event listeners that are listening on this node.
*/
getEventListenerInfo: function () {
if (this.rawNode.nodeName.toLowerCase() === "html") {
return this.getEventListeners(this.rawNode.ownerGlobal);
}
return this.getEventListeners(this.rawNode);
},
/**
* Modify a node's attributes. Passed an array of modifications
* similar in format to "attributes" mutations.
* {
* attributeName: <string>
* attributeNamespace: <optional string>
* newValue: <optional string> - If null or undefined, the attribute
* will be removed.
* }
*
* Returns when the modifications have been made. Mutations will
* be queued for any changes made.
*/
modifyAttributes: function (modifications) {
let rawNode = this.rawNode;
for (let change of modifications) {
if (change.newValue == null) {
if (change.attributeNamespace) {
rawNode.removeAttributeNS(change.attributeNamespace,
change.attributeName);
} else {
rawNode.removeAttribute(change.attributeName);
}
} else if (change.attributeNamespace) {
rawNode.setAttributeNS(change.attributeNamespace, change.attributeName,
change.newValue);
} else {
rawNode.setAttribute(change.attributeName, change.newValue);
}
}
},
/**
* Given the font and fill style, get the image data of a canvas with the
* preview text and font.
* Returns an imageData object with the actual data being a LongStringActor
* and the width of the text as a string.
* The image data is transmitted as a base64 encoded png data-uri.
*/
getFontFamilyDataURL: function (font, fillStyle = "black") {
let doc = this.rawNode.ownerDocument;
let options = {
previewText: FONT_FAMILY_PREVIEW_TEXT,
previewFontSize: FONT_FAMILY_PREVIEW_TEXT_SIZE,
fillStyle: fillStyle
};
let { dataURL, size } = getFontPreviewData(font, doc, options);
return { data: LongStringActor(this.conn, dataURL), size: size };
}
});
/**
* Server side of a node list as returned by querySelectorAll()
*/
var NodeListActor = exports.NodeListActor = protocol.ActorClassWithSpec(nodeListSpec, {
typeName: "domnodelist",
initialize: function (walker, nodeList) {
protocol.Actor.prototype.initialize.call(this);
this.walker = walker;
this.nodeList = nodeList || [];
},
destroy: function () {
protocol.Actor.prototype.destroy.call(this);
},
/**
* Instead of storing a connection object, the NodeActor gets its connection
* from its associated walker.
*/
get conn() {
return this.walker.conn;
},
/**
* Items returned by this actor should belong to the parent walker.
*/
marshallPool: function () {
return this.walker;
},
// Returns the JSON representation of this object over the wire.
form: function () {
return {
actor: this.actorID,
length: this.nodeList ? this.nodeList.length : 0
};
},
/**
* Get a single node from the node list.
*/
item: function (index) {
return this.walker.attachElement(this.nodeList[index]);
},
/**
* Get a range of the items from the node list.
*/
items: function (start = 0, end = this.nodeList.length) {
let items = Array.prototype.slice.call(this.nodeList, start, end)
.map(item => this.walker._ref(item));
return this.walker.attachElements(items);
},
release: function () {}
});
/**
* Server side of the DOM walker.
*/
var WalkerActor = protocol.ActorClassWithSpec(walkerSpec, {
/**
* Create the WalkerActor
* @param DebuggerServerConnection conn
* The server connection.
*/
initialize: function (conn, tabActor, options) {
protocol.Actor.prototype.initialize.call(this, conn);
this.tabActor = tabActor;
this.rootWin = tabActor.window;
this.rootDoc = this.rootWin.document;
this._refMap = new Map();
this._pendingMutations = [];
this._activePseudoClassLocks = new Set();
this.showAllAnonymousContent = options.showAllAnonymousContent;
this.walkerSearch = new WalkerSearch(this);
// Nodes which have been removed from the client's known
// ownership tree are considered "orphaned", and stored in
// this set.
this._orphaned = new Set();
// The client can tell the walker that it is interested in a node
// even when it is orphaned with the `retainNode` method. This
// list contains orphaned nodes that were so retained.
this._retainedOrphans = new Set();
this.onMutations = this.onMutations.bind(this);
this.onFrameLoad = this.onFrameLoad.bind(this);
this.onFrameUnload = this.onFrameUnload.bind(this);
events.on(tabActor, "will-navigate", this.onFrameUnload);
events.on(tabActor, "navigate", this.onFrameLoad);
// Ensure that the root document node actor is ready and
// managed.
this.rootNode = this.document();
this.layoutChangeObserver = getLayoutChangesObserver(this.tabActor);
this._onReflows = this._onReflows.bind(this);
this.layoutChangeObserver.on("reflows", this._onReflows);
this._onResize = this._onResize.bind(this);
this.layoutChangeObserver.on("resize", this._onResize);
this._onEventListenerChange = this._onEventListenerChange.bind(this);
eventListenerService.addListenerChangeListener(this._onEventListenerChange);
},
/**
* Callback for eventListenerService.addListenerChangeListener
* @param nsISimpleEnumerator changesEnum
* enumerator of nsIEventListenerChange
*/
_onEventListenerChange: function (changesEnum) {
let changes = changesEnum.enumerate();
while (changes.hasMoreElements()) {
let current = changes.getNext().QueryInterface(Ci.nsIEventListenerChange);
let target = current.target;
if (this._refMap.has(target)) {
let actor = this.getNode(target);
let mutation = {
type: "events",
target: actor.actorID,
hasEventListeners: actor._hasEventListeners
};
this.queueMutation(mutation);
}
}
},
// Returns the JSON representation of this object over the wire.
form: function () {
return {
actor: this.actorID,
root: this.rootNode.form(),
traits: {
// FF42+ Inspector starts managing the Walker, while the inspector also
// starts cleaning itself up automatically on client disconnection.
// So that there is no need to manually release the walker anymore.
autoReleased: true,
// XXX: It seems silly that we need to tell the front which capabilities
// its actor has in this way when the target can use actorHasMethod. If
// this was ported to the protocol (Bug 1157048) we could call that
// inside of custom front methods and not need to do traits for this.
multiFrameQuerySelectorAll: true,
textSearch: true,
}
};
},
toString: function () {
return "[WalkerActor " + this.actorID + "]";
},
getDocumentWalker: function (node, whatToShow) {
// Allow native anon content (like <video> controls) if preffed on
let nodeFilter = this.showAllAnonymousContent
? allAnonymousContentTreeWalkerFilter
: standardTreeWalkerFilter;
return new DocumentWalker(node, this.rootWin, whatToShow, nodeFilter);
},
destroy: function () {
if (this._destroyed) {
return;
}
this._destroyed = true;
protocol.Actor.prototype.destroy.call(this);
try {
this.clearPseudoClassLocks();
this._activePseudoClassLocks = null;
this._hoveredNode = null;
this.rootWin = null;
this.rootDoc = null;
this.rootNode = null;
this.layoutHelpers = null;
this._orphaned = null;
this._retainedOrphans = null;
this._refMap = null;
events.off(this.tabActor, "will-navigate", this.onFrameUnload);
events.off(this.tabActor, "navigate", this.onFrameLoad);
this.onFrameLoad = null;
this.onFrameUnload = null;
this.walkerSearch.destroy();
this.layoutChangeObserver.off("reflows", this._onReflows);
this.layoutChangeObserver.off("resize", this._onResize);
this.layoutChangeObserver = null;
releaseLayoutChangesObserver(this.tabActor);
eventListenerService.removeListenerChangeListener(
this._onEventListenerChange);
this.onMutations = null;
this.layoutActor = null;
this.tabActor = null;
events.emit(this, "destroyed");
} catch (e) {
console.error(e);
}
},
release: function () {},
unmanage: function (actor) {
if (actor instanceof NodeActor) {
if (this._activePseudoClassLocks &&
this._activePseudoClassLocks.has(actor)) {
this.clearPseudoClassLocks(actor);
}
this._refMap.delete(actor.rawNode);
}
protocol.Actor.prototype.unmanage.call(this, actor);
},
/**
* Determine if the walker has come across this DOM node before.
* @param {DOMNode} rawNode
* @return {Boolean}
*/
hasNode: function (rawNode) {
return this._refMap.has(rawNode);
},
/**
* If the walker has come across this DOM node before, then get the
* corresponding node actor.
* @param {DOMNode} rawNode
* @return {NodeActor}
*/
getNode: function (rawNode) {
return this._refMap.get(rawNode);
},
_ref: function (node) {
let actor = this.getNode(node);
if (actor) {
return actor;
}
actor = new NodeActor(this, node);
// Add the node actor as a child of this walker actor, assigning
// it an actorID.
this.manage(actor);
this._refMap.set(node, actor);
if (node.nodeType === Ci.nsIDOMNode.DOCUMENT_NODE) {
actor.watchDocument(this.onMutations);
}
return actor;
},
_onReflows: function (reflows) {
// Going through the nodes the walker knows about, see which ones have
// had their display changed and send a display-change event if any
let changes = [];
for (let [node, actor] of this._refMap) {
if (Cu.isDeadWrapper(node)) {
continue;
}
let isDisplayed = actor.isDisplayed;
if (isDisplayed !== actor.wasDisplayed) {
changes.push(actor);
// Updating the original value
actor.wasDisplayed = isDisplayed;
}
}
if (changes.length) {
events.emit(this, "display-change", changes);
}
},
/**
* When the browser window gets resized, relay the event to the front.
*/
_onResize: function () {
events.emit(this, "resize");
},
/**
* This is kept for backward-compatibility reasons with older remote targets.
* Targets prior to bug 916443.
*
* pick/cancelPick are used to pick a node on click on the content
* document. But in their implementation prior to bug 916443, they don't allow
* highlighting on hover.
* The client-side now uses the highlighter actor's pick and cancelPick
* methods instead. The client-side uses the the highlightable trait found in
* the root actor to determine which version of pick to use.
*
* As for highlight, the new highlighter actor is used instead of the walker's
* highlight method. Same here though, the client-side uses the highlightable
* trait to dertermine which to use.
*
* Keeping these actor methods for now allows newer client-side debuggers to
* inspect fxos 1.2 remote targets or older firefox desktop remote targets.
*/
pick: function () {},
cancelPick: function () {},
highlight: function (node) {},
/**
* Ensures that the node is attached and it can be accessed from the root.
*
* @param {(Node|NodeActor)} nodes The nodes
* @return {Object} An object compatible with the disconnectedNode type.
*/
attachElement: function (node) {
let { nodes, newParents } = this.attachElements([node]);
return {
node: nodes[0],
newParents: newParents
};
},
/**
* Ensures that the nodes are attached and they can be accessed from the root.
*
* @param {(Node[]|NodeActor[])} nodes The nodes
* @return {Object} An object compatible with the disconnectedNodeArray type.
*/
attachElements: function (nodes) {
let nodeActors = [];
let newParents = new Set();
for (let node of nodes) {
if (!(node instanceof NodeActor)) {
// If an anonymous node was passed in and we aren't supposed to know
// about it, then consult with the document walker as the source of
// truth about which elements exist.
if (!this.showAllAnonymousContent && isAnonymous(node)) {
node = this.getDocumentWalker(node).currentNode;
}
node = this._ref(node);
}
this.ensurePathToRoot(node, newParents);
// If nodes may be an array of raw nodes, we're sure to only have
// NodeActors with the following array.
nodeActors.push(node);
}
return {
nodes: nodeActors,
newParents: [...newParents]
};
},
/**
* Return the document node that contains the given node,
* or the root node if no node is specified.
* @param NodeActor node
* The node whose document is needed, or null to
* return the root.
*/
document: function (node) {
let doc = isNodeDead(node) ? this.rootDoc : nodeDocument(node.rawNode);
return this._ref(doc);
},
/**
* Return the documentElement for the document containing the
* given node.
* @param NodeActor node
* The node whose documentElement is requested, or null
* to use the root document.
*/
documentElement: function (node) {
let elt = isNodeDead(node)
? this.rootDoc.documentElement
: nodeDocument(node.rawNode).documentElement;
return this._ref(elt);
},
/**
* Return all parents of the given node, ordered from immediate parent
* to root.
* @param NodeActor node
* The node whose parents are requested.
* @param object options
* Named options, including:
* `sameDocument`: If true, parents will be restricted to the same
* document as the node.
* `sameTypeRootTreeItem`: If true, this will not traverse across
* different types of docshells.
*/
parents: function (node, options = {}) {
if (isNodeDead(node)) {
return [];
}
let walker = this.getDocumentWalker(node.rawNode);
let parents = [];
let cur;
while ((cur = walker.parentNode())) {
if (options.sameDocument &&
nodeDocument(cur) != nodeDocument(node.rawNode)) {
break;
}
if (options.sameTypeRootTreeItem &&
nodeDocshell(cur).sameTypeRootTreeItem !=
nodeDocshell(node.rawNode).sameTypeRootTreeItem) {
break;
}
parents.push(this._ref(cur));
}
return parents;
},
parentNode: function (node) {
let walker = this.getDocumentWalker(node.rawNode);
let parent = walker.parentNode();
if (parent) {
return this._ref(parent);
}
return null;
},
/**
* If the given NodeActor only has a single text node as a child with a text
* content small enough to be inlined, return that child's NodeActor.
*
* @param NodeActor node
*/
inlineTextChild: function (node) {
// Quick checks to prevent creating a new walker if possible.
if (node.isBeforePseudoElement ||
node.isAfterPseudoElement ||
node.rawNode.nodeType != Ci.nsIDOMNode.ELEMENT_NODE ||
node.rawNode.children.length > 0) {
return undefined;
}
let docWalker = this.getDocumentWalker(node.rawNode);
let firstChild = docWalker.firstChild();
// Bail out if:
// - more than one child
// - unique child is not a text node
// - unique child is a text node, but is too long to be inlined
if (!firstChild ||
docWalker.nextSibling() ||
firstChild.nodeType !== Ci.nsIDOMNode.TEXT_NODE ||
firstChild.nodeValue.length > gValueSummaryLength
) {
return undefined;
}
return this._ref(firstChild);
},
/**
* Mark a node as 'retained'.
*
* A retained node is not released when `releaseNode` is called on its
* parent, or when a parent is released with the `cleanup` option to
* `getMutations`.
*
* When a retained node's parent is released, a retained mode is added to
* the walker's "retained orphans" list.
*
* Retained nodes can be deleted by providing the `force` option to
* `releaseNode`. They will also be released when their document
* has been destroyed.
*
* Retaining a node makes no promise about its children; They can
* still be removed by normal means.
*/
retainNode: function (node) {
node.retained = true;
},
/**
* Remove the 'retained' mark from a node. If the node was a
* retained orphan, release it.
*/
unretainNode: function (node) {
node.retained = false;
if (this._retainedOrphans.has(node)) {
this._retainedOrphans.delete(node);
this.releaseNode(node);
}
},
/**
* Release actors for a node and all child nodes.
*/
releaseNode: function (node, options = {}) {
if (isNodeDead(node)) {
return;
}
if (node.retained && !options.force) {
this._retainedOrphans.add(node);
return;
}
if (node.retained) {
// Forcing a retained node to go away.
this._retainedOrphans.delete(node);
}
let walker = this.getDocumentWalker(node.rawNode);
let child = walker.firstChild();
while (child) {
let childActor = this.getNode(child);
if (childActor) {
this.releaseNode(childActor, options);
}
child = walker.nextSibling();
}
node.destroy();
},
/**
* Add any nodes between `node` and the walker's root node that have not
* yet been seen by the client.
*/
ensurePathToRoot: function (node, newParents = new Set()) {
if (!node) {
return newParents;
}
let walker = this.getDocumentWalker(node.rawNode);
let cur;
while ((cur = walker.parentNode())) {
let parent = this.getNode(cur);
if (!parent) {
// This parent didn't exist, so hasn't been seen by the client yet.
newParents.add(this._ref(cur));
} else {
// This parent did exist, so the client knows about it.
return newParents;
}
}
return newParents;
},
/**
* Return children of the given node. By default this method will return
* all children of the node, but there are options that can restrict this
* to a more manageable subset.
*
* @param NodeActor node
* The node whose children you're curious about.
* @param object options
* Named options:
* `maxNodes`: The set of nodes returned by the method will be no longer
* than maxNodes.
* `start`: If a node is specified, the list of nodes will start
* with the given child. Mutally exclusive with `center`.
* `center`: If a node is specified, the given node will be as centered
* as possible in the list, given how close to the ends of the child
* list it is. Mutually exclusive with `start`.
* `whatToShow`: A bitmask of node types that should be included. See
* https://developer.mozilla.org/en-US/docs/Web/API/NodeFilter.
*
* @returns an object with three items:
* hasFirst: true if the first child of the node is included in the list.
* hasLast: true if the last child of the node is included in the list.
* nodes: Child nodes returned by the request.
*/
children: function (node, options = {}) {
if (isNodeDead(node)) {
return { hasFirst: true, hasLast: true, nodes: [] };
}
if (options.center && options.start) {
throw Error("Can't specify both 'center' and 'start' options.");
}
let maxNodes = options.maxNodes || -1;
if (maxNodes == -1) {
maxNodes = Number.MAX_VALUE;
}
// We're going to create a few document walkers with the same filter,
// make it easier.
let getFilteredWalker = documentWalkerNode => {
return this.getDocumentWalker(documentWalkerNode, options.whatToShow);
};
// Need to know the first and last child.
let rawNode = node.rawNode;
let firstChild = getFilteredWalker(rawNode).firstChild();
let lastChild = getFilteredWalker(rawNode).lastChild();
if (!firstChild) {
// No children, we're done.
return { hasFirst: true, hasLast: true, nodes: [] };
}
let start;
if (options.center) {
start = options.center.rawNode;
} else if (options.start) {
start = options.start.rawNode;
} else {
start = firstChild;
}
let nodes = [];
// Start by reading backward from the starting point if we're centering...
let backwardWalker = getFilteredWalker(start);
if (start != firstChild && options.center) {
backwardWalker.previousSibling();
let backwardCount = Math.floor(maxNodes / 2);
let backwardNodes = this._readBackward(backwardWalker, backwardCount);
nodes = backwardNodes;
}
// Then read forward by any slack left in the max children...
let forwardWalker = getFilteredWalker(start);
let forwardCount = maxNodes - nodes.length;
nodes = nodes.concat(this._readForward(forwardWalker, forwardCount));
// If there's any room left, it means we've run all the way to the end.
// If we're centering, check if there are more items to read at the front.
let remaining = maxNodes - nodes.length;
if (options.center && remaining > 0 && nodes[0].rawNode != firstChild) {
let firstNodes = this._readBackward(backwardWalker, remaining);
// Then put it all back together.
nodes = firstNodes.concat(nodes);
}
return {
hasFirst: nodes[0].rawNode == firstChild,
hasLast: nodes[nodes.length - 1].rawNode == lastChild,
nodes: nodes
};
},
/**
* Return siblings of the given node. By default this method will return
* all siblings of the node, but there are options that can restrict this
* to a more manageable subset.
*
* If `start` or `center` are not specified, this method will center on the
* node whose siblings are requested.
*
* @param NodeActor node
* The node whose children you're curious about.
* @param object options
* Named options:
* `maxNodes`: The set of nodes returned by the method will be no longer
* than maxNodes.
* `start`: If a node is specified, the list of nodes will start
* with the given child. Mutally exclusive with `center`.
* `center`: If a node is specified, the given node will be as centered
* as possible in the list, given how close to the ends of the child
* list it is. Mutually exclusive with `start`.
* `whatToShow`: A bitmask of node types that should be included. See
* https://developer.mozilla.org/en-US/docs/Web/API/NodeFilter.
*
* @returns an object with three items:
* hasFirst: true if the first child of the node is included in the list.
* hasLast: true if the last child of the node is included in the list.
* nodes: Child nodes returned by the request.
*/
siblings: function (node, options = {}) {
if (isNodeDead(node)) {
return { hasFirst: true, hasLast: true, nodes: [] };
}
let parentNode = this.getDocumentWalker(node.rawNode, options.whatToShow)
.parentNode();
if (!parentNode) {
return {
hasFirst: true,
hasLast: true,
nodes: [node]
};
}
if (!(options.start || options.center)) {
options.center = node;
}
return this.children(this._ref(parentNode), options);
},
/**
* Get the next sibling of a given node. Getting nodes one at a time
* might be inefficient, be careful.
*
* @param object options
* Named options:
* `whatToShow`: A bitmask of node types that should be included. See
* https://developer.mozilla.org/en-US/docs/Web/API/NodeFilter.
*/
nextSibling: function (node, options = {}) {
if (isNodeDead(node)) {
return null;
}
let walker = this.getDocumentWalker(node.rawNode, options.whatToShow);
let sibling = walker.nextSibling();
return sibling ? this._ref(sibling) : null;
},
/**
* Get the previous sibling of a given node. Getting nodes one at a time
* might be inefficient, be careful.
*
* @param object options
* Named options:
* `whatToShow`: A bitmask of node types that should be included. See
* https://developer.mozilla.org/en-US/docs/Web/API/NodeFilter.
*/
previousSibling: function (node, options = {}) {
if (isNodeDead(node)) {
return null;
}
let walker = this.getDocumentWalker(node.rawNode, options.whatToShow);
let sibling = walker.previousSibling();
return sibling ? this._ref(sibling) : null;
},
/**
* Helper function for the `children` method: Read forward in the sibling
* list into an array with `count` items, including the current node.
*/
_readForward: function (walker, count) {
let ret = [];
let node = walker.currentNode;
do {
ret.push(this._ref(node));
node = walker.nextSibling();
} while (node && --count);
return ret;
},
/**
* Helper function for the `children` method: Read backward in the sibling
* list into an array with `count` items, including the current node.
*/
_readBackward: function (walker, count) {
let ret = [];
let node = walker.currentNode;
do {
ret.push(this._ref(node));
node = walker.previousSibling();
} while (node && --count);
ret.reverse();
return ret;
},
/**
* Return the node that the parent process has asked to
* inspect. This node is expected to be stored in gInspectingNode
* (which is set by a message manager message to the child.js frame
* script). The node is returned over the remote debugging protocol
* as a NodeFront.
*/
findInspectingNode: function () {
let node = gInspectingNode;
if (!node) {
return {};
}
return this.attachElement(node);
},
/**
* Return the first node in the document that matches the given selector.
* See https://developer.mozilla.org/en-US/docs/Web/API/Element.querySelector
*
* @param NodeActor baseNode
* @param string selector
*/
querySelector: function (baseNode, selector) {
if (isNodeDead(baseNode)) {
return {};
}
let node = baseNode.rawNode.querySelector(selector);
if (!node) {
return {};
}
return this.attachElement(node);
},
/**
* Return a NodeListActor with all nodes that match the given selector.
* See https://developer.mozilla.org/en-US/docs/Web/API/Element.querySelectorAll
*
* @param NodeActor baseNode
* @param string selector
*/
querySelectorAll: function (baseNode, selector) {
let nodeList = null;
try {
nodeList = baseNode.rawNode.querySelectorAll(selector);
} catch (e) {
// Bad selector. Do nothing as the selector can come from a searchbox.
}
return new NodeListActor(this, nodeList);
},
/**
* Get a list of nodes that match the given selector in all known frames of
* the current content page.
* @param {String} selector.
* @return {Array}
*/
_multiFrameQuerySelectorAll: function (selector) {
let nodes = [];
for (let {document} of this.tabActor.windows) {
try {
nodes = [...nodes, ...document.querySelectorAll(selector)];
} catch (e) {
// Bad selector. Do nothing as the selector can come from a searchbox.
}
}
return nodes;
},
/**
* Return a NodeListActor with all nodes that match the given selector in all
* frames of the current content page.
* @param {String} selector
*/
multiFrameQuerySelectorAll: function (selector) {
return new NodeListActor(this, this._multiFrameQuerySelectorAll(selector));
},
/**
* Search the document for a given string.
* Results will be searched with the walker-search module (searches through
* tag names, attribute names and values, and text contents).
*
* @returns {searchresult}
* - {NodeList} list
* - {Array<Object>} metadata. Extra information with indices that
* match up with node list.
*/
search: function (query) {
let results = this.walkerSearch.search(query);
let nodeList = new NodeListActor(this, results.map(r => r.node));
return {
list: nodeList,
metadata: []
};
},
/**
* Returns a list of matching results for CSS selector autocompletion.
*
* @param string query
* The selector query being completed
* @param string completing
* The exact token being completed out of the query
* @param string selectorState
* One of "pseudo", "id", "tag", "class", "null"
*/
getSuggestionsForQuery: function (query, completing, selectorState) {
let sugs = {
classes: new Map(),
tags: new Map(),
ids: new Map()
};
let result = [];
let nodes = null;
// Filtering and sorting the results so that protocol transfer is miminal.
switch (selectorState) {
case "pseudo":
result = PSEUDO_SELECTORS.filter(item => {
return item[0].startsWith(":" + completing);
});
break;
case "class":
if (!query) {
nodes = this._multiFrameQuerySelectorAll("[class]");
} else {
nodes = this._multiFrameQuerySelectorAll(query);
}
for (let node of nodes) {
for (let className of node.classList) {
sugs.classes.set(className, (sugs.classes.get(className)|0) + 1);
}
}
sugs.classes.delete("");
sugs.classes.delete(HIDDEN_CLASS);
for (let [className, count] of sugs.classes) {
if (className.startsWith(completing)) {
result.push(["." + CSS.escape(className), count, selectorState]);
}
}
break;
case "id":
if (!query) {
nodes = this._multiFrameQuerySelectorAll("[id]");
} else {
nodes = this._multiFrameQuerySelectorAll(query);
}
for (let node of nodes) {
sugs.ids.set(node.id, (sugs.ids.get(node.id)|0) + 1);
}
for (let [id, count] of sugs.ids) {
if (id.startsWith(completing) && id !== "") {
result.push(["#" + CSS.escape(id), count, selectorState]);
}
}
break;
case "tag":
if (!query) {
nodes = this._multiFrameQuerySelectorAll("*");
} else {
nodes = this._multiFrameQuerySelectorAll(query);
}
for (let node of nodes) {
let tag = node.localName;
sugs.tags.set(tag, (sugs.tags.get(tag)|0) + 1);
}
for (let [tag, count] of sugs.tags) {
if ((new RegExp("^" + completing + ".*", "i")).test(tag)) {
result.push([tag, count, selectorState]);
}
}
// For state 'tag' (no preceding # or .) and when there's no query (i.e.
// only one word) then search for the matching classes and ids
if (!query) {
result = [
...result,
...this.getSuggestionsForQuery(null, completing, "class")
.suggestions,
...this.getSuggestionsForQuery(null, completing, "id")
.suggestions
];
}
break;
case "null":
nodes = this._multiFrameQuerySelectorAll(query);
for (let node of nodes) {
sugs.ids.set(node.id, (sugs.ids.get(node.id)|0) + 1);
let tag = node.localName;
sugs.tags.set(tag, (sugs.tags.get(tag)|0) + 1);
for (let className of node.classList) {
sugs.classes.set(className, (sugs.classes.get(className)|0) + 1);
}
}
for (let [tag, count] of sugs.tags) {
tag && result.push([tag, count]);
}
for (let [id, count] of sugs.ids) {
id && result.push(["#" + id, count]);
}
sugs.classes.delete("");
sugs.classes.delete(HIDDEN_CLASS);
for (let [className, count] of sugs.classes) {
className && result.push(["." + className, count]);
}
}
// Sort by count (desc) and name (asc)
result = result.sort((a, b) => {
// Computed a sortable string with first the inverted count, then the name
let sortA = (10000 - a[1]) + a[0];
let sortB = (10000 - b[1]) + b[0];
// Prefixing ids, classes and tags, to group results
let firstA = a[0].substring(0, 1);
let firstB = b[0].substring(0, 1);
if (firstA === "#") {
sortA = "2" + sortA;
} else if (firstA === ".") {
sortA = "1" + sortA;
} else {
sortA = "0" + sortA;
}
if (firstB === "#") {
sortB = "2" + sortB;
} else if (firstB === ".") {
sortB = "1" + sortB;
} else {
sortB = "0" + sortB;
}
// String compare
return sortA.localeCompare(sortB);
});
result.slice(0, 25);
return {
query: query,
suggestions: result
};
},
/**
* Add a pseudo-class lock to a node.
*
* @param NodeActor node
* @param string pseudo
* A pseudoclass: ':hover', ':active', ':focus'
* @param options
* Options object:
* `parents`: True if the pseudo-class should be added
* to parent nodes.
*
* @returns An empty packet. A "pseudoClassLock" mutation will
* be queued for any changed nodes.
*/
addPseudoClassLock: function (node, pseudo, options = {}) {
if (isNodeDead(node)) {
return;
}
// There can be only one node locked per pseudo, so dismiss all existing
// ones
for (let locked of this._activePseudoClassLocks) {
if (DOMUtils.hasPseudoClassLock(locked.rawNode, pseudo)) {
this._removePseudoClassLock(locked, pseudo);
}
}
this._addPseudoClassLock(node, pseudo);
if (!options.parents) {
return;
}
let walker = this.getDocumentWalker(node.rawNode);
let cur;
while ((cur = walker.parentNode())) {
let curNode = this._ref(cur);
this._addPseudoClassLock(curNode, pseudo);
}
},
_queuePseudoClassMutation: function (node) {
this.queueMutation({
target: node.actorID,
type: "pseudoClassLock",
pseudoClassLocks: node.writePseudoClassLocks()
});
},
_addPseudoClassLock: function (node, pseudo) {
if (node.rawNode.nodeType !== Ci.nsIDOMNode.ELEMENT_NODE) {
return false;
}
DOMUtils.addPseudoClassLock(node.rawNode, pseudo);
this._activePseudoClassLocks.add(node);
this._queuePseudoClassMutation(node);
return true;
},
_installHelperSheet: function (node) {
if (!this.installedHelpers) {
this.installedHelpers = new WeakMap();
}
let win = node.rawNode.ownerDocument.defaultView;
if (!this.installedHelpers.has(win)) {
let { Style } = require("sdk/stylesheet/style");
let { attach } = require("sdk/content/mod");
let style = Style({source: HELPER_SHEET, type: "agent" });
attach(style, win);
this.installedHelpers.set(win, style);
}
},
hideNode: function (node) {
if (isNodeDead(node)) {
return;
}
this._installHelperSheet(node);
node.rawNode.classList.add(HIDDEN_CLASS);
},
unhideNode: function (node) {
if (isNodeDead(node)) {
return;
}
node.rawNode.classList.remove(HIDDEN_CLASS);
},
/**
* Remove a pseudo-class lock from a node.
*
* @param NodeActor node
* @param string pseudo
* A pseudoclass: ':hover', ':active', ':focus'
* @param options
* Options object:
* `parents`: True if the pseudo-class should be removed
* from parent nodes.
*
* @returns An empty response. "pseudoClassLock" mutations
* will be emitted for any changed nodes.
*/
removePseudoClassLock: function (node, pseudo, options = {}) {
if (isNodeDead(node)) {
return;
}
this._removePseudoClassLock(node, pseudo);
// Remove pseudo class for children as we don't want to allow
// turning it on for some childs without setting it on some parents
for (let locked of this._activePseudoClassLocks) {
if (node.rawNode.contains(locked.rawNode) &&
DOMUtils.hasPseudoClassLock(locked.rawNode, pseudo)) {
this._removePseudoClassLock(locked, pseudo);
}
}
if (!options.parents) {
return;
}
let walker = this.getDocumentWalker(node.rawNode);
let cur;
while ((cur = walker.parentNode())) {
let curNode = this._ref(cur);
this._removePseudoClassLock(curNode, pseudo);
}
},
_removePseudoClassLock: function (node, pseudo) {
if (node.rawNode.nodeType != Ci.nsIDOMNode.ELEMENT_NODE) {
return false;
}
DOMUtils.removePseudoClassLock(node.rawNode, pseudo);
if (!node.writePseudoClassLocks()) {
this._activePseudoClassLocks.delete(node);
}
this._queuePseudoClassMutation(node);
return true;
},
/**
* Clear all the pseudo-classes on a given node or all nodes.
* @param {NodeActor} node Optional node to clear pseudo-classes on
*/
clearPseudoClassLocks: function (node) {
if (node && isNodeDead(node)) {
return;
}
if (node) {
DOMUtils.clearPseudoClassLocks(node.rawNode);
this._activePseudoClassLocks.delete(node);
this._queuePseudoClassMutation(node);
} else {
for (let locked of this._activePseudoClassLocks) {
DOMUtils.clearPseudoClassLocks(locked.rawNode);
this._activePseudoClassLocks.delete(locked);
this._queuePseudoClassMutation(locked);
}
}
},
/**
* Get a node's innerHTML property.
*/
innerHTML: function (node) {
let html = "";
if (!isNodeDead(node)) {
html = node.rawNode.innerHTML;
}
return LongStringActor(this.conn, html);
},
/**
* Set a node's innerHTML property.
*
* @param {NodeActor} node The node.
* @param {string} value The piece of HTML content.
*/
setInnerHTML: function (node, value) {
if (isNodeDead(node)) {
return;
}
let rawNode = node.rawNode;
if (rawNode.nodeType !== rawNode.ownerDocument.ELEMENT_NODE) {
throw new Error("Can only change innerHTML to element nodes");
}
rawNode.innerHTML = value;
},
/**
* Get a node's outerHTML property.
*
* @param {NodeActor} node The node.
*/
outerHTML: function (node) {
let outerHTML = "";
if (!isNodeDead(node)) {
outerHTML = node.rawNode.outerHTML;
}
return LongStringActor(this.conn, outerHTML);
},
/**
* Set a node's outerHTML property.
*
* @param {NodeActor} node The node.
* @param {string} value The piece of HTML content.
*/
setOuterHTML: function (node, value) {
if (isNodeDead(node)) {
return;
}
let parsedDOM = DOMParser.parseFromString(value, "text/html");
let rawNode = node.rawNode;
let parentNode = rawNode.parentNode;
// Special case for head and body. Setting document.body.outerHTML
// creates an extra <head> tag, and document.head.outerHTML creates
// an extra <body>. So instead we will call replaceChild with the
// parsed DOM, assuming that they aren't trying to set both tags at once.
if (rawNode.tagName === "BODY") {
if (parsedDOM.head.innerHTML === "") {
parentNode.replaceChild(parsedDOM.body, rawNode);
} else {
rawNode.outerHTML = value;
}
} else if (rawNode.tagName === "HEAD") {
if (parsedDOM.body.innerHTML === "") {
parentNode.replaceChild(parsedDOM.head, rawNode);
} else {
rawNode.outerHTML = value;
}
} else if (node.isDocumentElement()) {
// Unable to set outerHTML on the document element. Fall back by
// setting attributes manually, then replace the body and head elements.
let finalAttributeModifications = [];
let attributeModifications = {};
for (let attribute of rawNode.attributes) {
attributeModifications[attribute.name] = null;
}
for (let attribute of parsedDOM.documentElement.attributes) {
attributeModifications[attribute.name] = attribute.value;
}
for (let key in attributeModifications) {
finalAttributeModifications.push({
attributeName: key,
newValue: attributeModifications[key]
});
}
node.modifyAttributes(finalAttributeModifications);
rawNode.replaceChild(parsedDOM.head, rawNode.querySelector("head"));
rawNode.replaceChild(parsedDOM.body, rawNode.querySelector("body"));
} else {
rawNode.outerHTML = value;
}
},
/**
* Insert adjacent HTML to a node.
*
* @param {Node} node
* @param {string} position One of "beforeBegin", "afterBegin", "beforeEnd",
* "afterEnd" (see Element.insertAdjacentHTML).
* @param {string} value The HTML content.
*/
insertAdjacentHTML: function (node, position, value) {
if (isNodeDead(node)) {
return {node: [], newParents: []};
}
let rawNode = node.rawNode;
let isInsertAsSibling = position === "beforeBegin" ||
position === "afterEnd";
// Don't insert anything adjacent to the document element.
if (isInsertAsSibling && node.isDocumentElement()) {
throw new Error("Can't insert adjacent element to the root.");
}
let rawParentNode = rawNode.parentNode;
if (!rawParentNode && isInsertAsSibling) {
throw new Error("Can't insert as sibling without parent node.");
}
// We can't use insertAdjacentHTML, because we want to return the nodes
// being created (so the front can remove them if the user undoes
// the change). So instead, use Range.createContextualFragment().
let range = rawNode.ownerDocument.createRange();
if (position === "beforeBegin" || position === "afterEnd") {
range.selectNode(rawNode);
} else {
range.selectNodeContents(rawNode);
}
let docFrag = range.createContextualFragment(value);
let newRawNodes = Array.from(docFrag.childNodes);
switch (position) {
case "beforeBegin":
rawParentNode.insertBefore(docFrag, rawNode);
break;
case "afterEnd":
// Note: if the second argument is null, rawParentNode.insertBefore
// behaves like rawParentNode.appendChild.
rawParentNode.insertBefore(docFrag, rawNode.nextSibling);
break;
case "afterBegin":
rawNode.insertBefore(docFrag, rawNode.firstChild);
break;
case "beforeEnd":
rawNode.appendChild(docFrag);
break;
default:
throw new Error("Invalid position value. Must be either " +
"'beforeBegin', 'beforeEnd', 'afterBegin' or 'afterEnd'.");
}
return this.attachElements(newRawNodes);
},
/**
* Duplicate a specified node
*
* @param {NodeActor} node The node to duplicate.
*/
duplicateNode: function ({rawNode}) {
let clonedNode = rawNode.cloneNode(true);
rawNode.parentNode.insertBefore(clonedNode, rawNode.nextSibling);
},
/**
* Test whether a node is a document or a document element.
*
* @param {NodeActor} node The node to remove.
* @return {boolean} True if the node is a document or a document element.
*/
isDocumentOrDocumentElementNode: function (node) {
return ((node.rawNode.ownerDocument &&
node.rawNode.ownerDocument.documentElement === this.rawNode) ||
node.rawNode.nodeType === Ci.nsIDOMNode.DOCUMENT_NODE);
},
/**
* Removes a node from its parent node.
*
* @param {NodeActor} node The node to remove.
* @returns The node's nextSibling before it was removed.
*/
removeNode: function (node) {
if (isNodeDead(node) || this.isDocumentOrDocumentElementNode(node)) {
throw Error("Cannot remove document, document elements or dead nodes.");
}
let nextSibling = this.nextSibling(node);
node.rawNode.remove();
// Mutation events will take care of the rest.
return nextSibling;
},
/**
* Removes an array of nodes from their parent node.
*
* @param {NodeActor[]} nodes The nodes to remove.
*/
removeNodes: function (nodes) {
// Check that all nodes are valid before processing the removals.
for (let node of nodes) {
if (isNodeDead(node) || this.isDocumentOrDocumentElementNode(node)) {
throw Error("Cannot remove document, document elements or dead nodes");
}
}
for (let node of nodes) {
node.rawNode.remove();
// Mutation events will take care of the rest.
}
},
/**
* Insert a node into the DOM.
*/
insertBefore: function (node, parent, sibling) {
if (isNodeDead(node) ||
isNodeDead(parent) ||
(sibling && isNodeDead(sibling))) {
return;
}
let rawNode = node.rawNode;
let rawParent = parent.rawNode;
let rawSibling = sibling ? sibling.rawNode : null;
// Don't bother inserting a node if the document position isn't going
// to change. This prevents needless iframes reloading and mutations.
if (rawNode.parentNode === rawParent) {
let currentNextSibling = this.nextSibling(node);
currentNextSibling = currentNextSibling ? currentNextSibling.rawNode :
null;
if (rawNode === rawSibling || currentNextSibling === rawSibling) {
return;
}
}
rawParent.insertBefore(rawNode, rawSibling);
},
/**
* Editing a node's tagname actually means creating a new node with the same
* attributes, removing the node and inserting the new one instead.
* This method does not return anything as mutation events are taking care of
* informing the consumers about changes.
*/
editTagName: function (node, tagName) {
if (isNodeDead(node)) {
return null;
}
let oldNode = node.rawNode;
// Create a new element with the same attributes as the current element and
// prepare to replace the current node with it.
let newNode;
try {
newNode = nodeDocument(oldNode).createElement(tagName);
} catch (x) {
// Failed to create a new element with that tag name, ignore the change,
// and signal the error to the front.
return Promise.reject(new Error("Could not change node's tagName to " + tagName));
}
let attrs = oldNode.attributes;
for (let i = 0; i < attrs.length; i++) {
newNode.setAttribute(attrs[i].name, attrs[i].value);
}
// Insert the new node, and transfer the old node's children.
oldNode.parentNode.insertBefore(newNode, oldNode);
while (oldNode.firstChild) {
newNode.appendChild(oldNode.firstChild);
}
oldNode.remove();
return null;
},
/**
* Get any pending mutation records. Must be called by the client after
* the `new-mutations` notification is received. Returns an array of
* mutation records.
*
* Mutation records have a basic structure:
*
* {
* type: attributes|characterData|childList,
* target: <domnode actor ID>,
* }
*
* And additional attributes based on the mutation type:
*
* `attributes` type:
* attributeName: <string> - the attribute that changed
* attributeNamespace: <string> - the attribute's namespace URI, if any.
* newValue: <string> - The new value of the attribute, if any.
*
* `characterData` type:
* newValue: <string> - the new nodeValue for the node
*
* `childList` type is returned when the set of children for a node
* has changed. Includes extra data, which can be used by the client to
* maintain its ownership subtree.
*
* added: array of <domnode actor ID> - The list of actors *previously
* seen by the client* that were added to the target node.
* removed: array of <domnode actor ID> The list of actors *previously
* seen by the client* that were removed from the target node.
* inlineTextChild: If the node now has a single text child, it will
* be sent here.
*
* Actors that are included in a MutationRecord's `removed` but
* not in an `added` have been removed from the client's ownership
* tree (either by being moved under a node the client has seen yet
* or by being removed from the tree entirely), and is considered
* 'orphaned'.
*
* Keep in mind that if a node that the client hasn't seen is moved
* into or out of the target node, it will not be included in the
* removedNodes and addedNodes list, so if the client is interested
* in the new set of children it needs to issue a `children` request.
*/
getMutations: function (options = {}) {
let pending = this._pendingMutations || [];
this._pendingMutations = [];
if (options.cleanup) {
for (let node of this._orphaned) {
// Release the orphaned node. Nodes or children that have been
// retained will be moved to this._retainedOrphans.
this.releaseNode(node);
}
this._orphaned = new Set();
}
return pending;
},
queueMutation: function (mutation) {
if (!this.actorID || this._destroyed) {
// We've been destroyed, don't bother queueing this mutation.
return;
}
// We only send the `new-mutations` notification once, until the client
// fetches mutations with the `getMutations` packet.
let needEvent = this._pendingMutations.length === 0;
this._pendingMutations.push(mutation);
if (needEvent) {
events.emit(this, "new-mutations");
}
},
/**
* Handles mutations from the DOM mutation observer API.
*
* @param array[MutationRecord] mutations
* See https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver#MutationRecord
*/
onMutations: function (mutations) {
// Notify any observers that want *all* mutations (even on nodes that aren't
// referenced). This is not sent over the protocol so can only be used by
// scripts running in the server process.
events.emit(this, "any-mutation");
for (let change of mutations) {
let targetActor = this.getNode(change.target);
if (!targetActor) {
continue;
}
let targetNode = change.target;
let type = change.type;
let mutation = {
type: type,
target: targetActor.actorID,
};
if (type === "attributes") {
mutation.attributeName = change.attributeName;
mutation.attributeNamespace = change.attributeNamespace || undefined;
mutation.newValue = targetNode.hasAttribute(mutation.attributeName) ?
targetNode.getAttribute(mutation.attributeName)
: null;
} else if (type === "characterData") {
mutation.newValue = targetNode.nodeValue;
this._maybeQueueInlineTextChildMutation(change, targetNode);
} else if (type === "childList" || type === "nativeAnonymousChildList") {
// Get the list of removed and added actors that the client has seen
// so that it can keep its ownership tree up to date.
let removedActors = [];
let addedActors = [];
for (let removed of change.removedNodes) {
let removedActor = this.getNode(removed);
if (!removedActor) {
// If the client never encountered this actor we don't need to
// mention that it was removed.
continue;
}
// While removed from the tree, nodes are saved as orphaned.
this._orphaned.add(removedActor);
removedActors.push(removedActor.actorID);
}
for (let added of change.addedNodes) {
let addedActor = this.getNode(added);
if (!addedActor) {
// If the client never encounted this actor we don't need to tell
// it about its addition for ownership tree purposes - if the
// client wants to see the new nodes it can ask for children.
continue;
}
// The actor is reconnected to the ownership tree, unorphan
// it and let the client know so that its ownership tree is up
// to date.
this._orphaned.delete(addedActor);
addedActors.push(addedActor.actorID);
}
mutation.numChildren = targetActor.numChildren;
mutation.removed = removedActors;
mutation.added = addedActors;
let inlineTextChild = this.inlineTextChild(targetActor);
if (inlineTextChild) {
mutation.inlineTextChild = inlineTextChild.form();
}
}
this.queueMutation(mutation);
}
},
/**
* Check if the provided mutation could change the way the target element is
* inlined with its parent node. If it might, a custom mutation of type
* "inlineTextChild" will be queued.
*
* @param {MutationRecord} mutation
* A characterData type mutation
*/
_maybeQueueInlineTextChildMutation: function (mutation) {
let {oldValue, target} = mutation;
let newValue = target.nodeValue;
let limit = gValueSummaryLength;
if ((oldValue.length <= limit && newValue.length <= limit) ||
(oldValue.length > limit && newValue.length > limit)) {
// Bail out if the new & old values are both below/above the size limit.
return;
}
let parentActor = this.getNode(target.parentNode);
if (!parentActor || parentActor.rawNode.children.length > 0) {
// If the parent node has other children, a character data mutation will
// not change anything regarding inlining text nodes.
return;
}
let inlineTextChild = this.inlineTextChild(parentActor);
this.queueMutation({
type: "inlineTextChild",
target: parentActor.actorID,
inlineTextChild:
inlineTextChild ? inlineTextChild.form() : undefined
});
},
onFrameLoad: function ({ window, isTopLevel }) {
if (isTopLevel) {
// If we initialize the inspector while the document is loading,
// we may already have a root document set in the constructor.
if (this.rootDoc && !Cu.isDeadWrapper(this.rootDoc) &&
this.rootDoc.defaultView) {
this.onFrameUnload({ window: this.rootDoc.defaultView });
}
this.rootDoc = window.document;
this.rootNode = this.document();
this.queueMutation({
type: "newRoot",
target: this.rootNode.form()
});
return;
}
let frame = getFrameElement(window);
let frameActor = this.getNode(frame);
if (!frameActor) {
return;
}
this.queueMutation({
type: "frameLoad",
target: frameActor.actorID,
});
// Send a childList mutation on the frame.
this.queueMutation({
type: "childList",
target: frameActor.actorID,
added: [],
removed: []
});
},
// Returns true if domNode is in window or a subframe.
_childOfWindow: function (window, domNode) {
let win = nodeDocument(domNode).defaultView;
while (win) {
if (win === window) {
return true;
}
win = getFrameElement(win);
}
return false;
},
onFrameUnload: function ({ window }) {
// Any retained orphans that belong to this document
// or its children need to be released, and a mutation sent
// to notify of that.
let releasedOrphans = [];
for (let retained of this._retainedOrphans) {
if (Cu.isDeadWrapper(retained.rawNode) ||
this._childOfWindow(window, retained.rawNode)) {
this._retainedOrphans.delete(retained);
releasedOrphans.push(retained.actorID);
this.releaseNode(retained, { force: true });
}
}
if (releasedOrphans.length > 0) {
this.queueMutation({
target: this.rootNode.actorID,
type: "unretained",
nodes: releasedOrphans
});
}
let doc = window.document;
let documentActor = this.getNode(doc);
if (!documentActor) {
return;
}
if (this.rootDoc === doc) {
this.rootDoc = null;
this.rootNode = null;
}
this.queueMutation({
type: "documentUnload",
target: documentActor.actorID
});
let walker = this.getDocumentWalker(doc);
let parentNode = walker.parentNode();
if (parentNode) {
// Send a childList mutation on the frame so that clients know
// they should reread the children list.
this.queueMutation({
type: "childList",
target: this.getNode(parentNode).actorID,
added: [],
removed: []
});
}
// Need to force a release of this node, because those nodes can't
// be accessed anymore.
this.releaseNode(documentActor, { force: true });
},
/**
* Check if a node is attached to the DOM tree of the current page.
* @param {nsIDomNode} rawNode
* @return {Boolean} false if the node is removed from the tree or within a
* document fragment
*/
_isInDOMTree: function (rawNode) {
let walker = this.getDocumentWalker(rawNode);
let current = walker.currentNode;
// Reaching the top of tree
while (walker.parentNode()) {
current = walker.currentNode;
}
// The top of the tree is a fragment or is not rootDoc, hence rawNode isn't
// attached
if (current.nodeType === Ci.nsIDOMNode.DOCUMENT_FRAGMENT_NODE ||
current !== this.rootDoc) {
return false;
}
// Otherwise the top of the tree is rootDoc, hence rawNode is in rootDoc
return true;
},
/**
* @see _isInDomTree
*/
isInDOMTree: function (node) {
if (isNodeDead(node)) {
return false;
}
return this._isInDOMTree(node.rawNode);
},
/**
* Given an ObjectActor (identified by its ID), commonly used in the debugger,
* webconsole and variablesView, return the corresponding inspector's
* NodeActor
*/
getNodeActorFromObjectActor: function (objectActorID) {
let actor = this.conn.getActor(objectActorID);
if (!actor) {
return null;
}
let debuggerObject = this.conn.getActor(objectActorID).obj;
let rawNode = debuggerObject.unsafeDereference();
if (!this._isInDOMTree(rawNode)) {
return null;
}
// This is a special case for the document object whereby it is considered
// as document.documentElement (the <html> node)
if (rawNode.defaultView && rawNode === rawNode.defaultView.document) {
rawNode = rawNode.documentElement;
}
return this.attachElement(rawNode);
},
/**
* Given a StyleSheetActor (identified by its ID), commonly used in the
* style-editor, get its ownerNode and return the corresponding walker's
* NodeActor.
* Note that getNodeFromActor was added later and can now be used instead.
*/
getStyleSheetOwnerNode: function (styleSheetActorID) {
return this.getNodeFromActor(styleSheetActorID, ["ownerNode"]);
},
/**
* This method can be used to retrieve NodeActor for DOM nodes from other
* actors in a way that they can later be highlighted in the page, or
* selected in the inspector.
* If an actor has a reference to a DOM node, and the UI needs to know about
* this DOM node (and possibly select it in the inspector), the UI should
* first retrieve a reference to the walkerFront:
*
* // Make sure the inspector/walker have been initialized first.
* toolbox.initInspector().then(() => {
* // Retrieve the walker.
* let walker = toolbox.walker;
* });
*
* And then call this method:
*
* // Get the nodeFront from my actor, passing the ID and properties path.
* walker.getNodeFromActor(myActorID, ["element"]).then(nodeFront => {
* // Use the nodeFront, e.g. select the node in the inspector.
* toolbox.getPanel("inspector").selection.setNodeFront(nodeFront);
* });
*
* @param {String} actorID The ID for the actor that has a reference to the
* DOM node.
* @param {Array} path Where, on the actor, is the DOM node stored. If in the
* scope of the actor, the node is available as `this.data.node`, then this
* should be ["data", "node"].
* @return {NodeActor} The attached NodeActor, or null if it couldn't be
* found.
*/
getNodeFromActor: function (actorID, path) {
let actor = this.conn.getActor(actorID);
if (!actor) {
return null;
}
let obj = actor;
for (let name of path) {
if (!(name in obj)) {
return null;
}
obj = obj[name];
}
return this.attachElement(obj);
},
/**
* Returns an instance of the LayoutActor that is used to retrieve CSS layout-related
* information.
*
* @return {LayoutActor}
*/
getLayoutInspector: function () {
if (!this.layoutActor) {
this.layoutActor = new LayoutActor(this.conn, this.tabActor, this);
}
return this.layoutActor;
},
});
/**
* Server side of the inspector actor, which is used to create
* inspector-related actors, including the walker.
*/
exports.InspectorActor = protocol.ActorClassWithSpec(inspectorSpec, {
initialize: function (conn, tabActor) {
protocol.Actor.prototype.initialize.call(this, conn);
this.tabActor = tabActor;
this._onColorPicked = this._onColorPicked.bind(this);
this._onColorPickCanceled = this._onColorPickCanceled.bind(this);
this.destroyEyeDropper = this.destroyEyeDropper.bind(this);
},
destroy: function () {
protocol.Actor.prototype.destroy.call(this);
this.destroyEyeDropper();
this._highlighterPromise = null;
this._pageStylePromise = null;
this._walkerPromise = null;
this.walker = null;
this.tabActor = null;
},
// Forces destruction of the actor and all its children
// like highlighter, walker and style actors.
disconnect: function () {
this.destroy();
},
get window() {
return this.tabActor.window;
},
getWalker: function (options = {}) {
if (this._walkerPromise) {
return this._walkerPromise;
}
let deferred = promise.defer();
this._walkerPromise = deferred.promise;
let window = this.window;
let domReady = () => {
let tabActor = this.tabActor;
window.removeEventListener("DOMContentLoaded", domReady, true);
this.walker = WalkerActor(this.conn, tabActor, options);
this.manage(this.walker);
events.once(this.walker, "destroyed", () => {
this._walkerPromise = null;
this._pageStylePromise = null;
});
deferred.resolve(this.walker);
};
if (window.document.readyState === "loading") {
window.addEventListener("DOMContentLoaded", domReady, true);
} else {
domReady();
}
return this._walkerPromise;
},
getPageStyle: function () {
if (this._pageStylePromise) {
return this._pageStylePromise;
}
this._pageStylePromise = this.getWalker().then(walker => {
let pageStyle = PageStyleActor(this);
this.manage(pageStyle);
return pageStyle;
});
return this._pageStylePromise;
},
/**
* The most used highlighter actor is the HighlighterActor which can be
* conveniently retrieved by this method.
* The same instance will always be returned by this method when called
* several times.
* The highlighter actor returned here is used to highlighter elements's
* box-models from the markup-view, box model, console, debugger, ... as
* well as select elements with the pointer (pick).
*
* @param {Boolean} autohide Optionally autohide the highlighter after an
* element has been picked
* @return {HighlighterActor}
*/
getHighlighter: function (autohide) {
if (this._highlighterPromise) {
return this._highlighterPromise;
}
this._highlighterPromise = this.getWalker().then(walker => {
let highlighter = HighlighterActor(this, autohide);
this.manage(highlighter);
return highlighter;
});
return this._highlighterPromise;
},
/**
* If consumers need to display several highlighters at the same time or
* different types of highlighters, then this method should be used, passing
* the type name of the highlighter needed as argument.
* A new instance will be created everytime the method is called, so it's up
* to the consumer to release it when it is not needed anymore
*
* @param {String} type The type of highlighter to create
* @return {Highlighter} The highlighter actor instance or null if the
* typeName passed doesn't match any available highlighter
*/
getHighlighterByType: function (typeName) {
if (isTypeRegistered(typeName)) {
return CustomHighlighterActor(this, typeName);
}
return null;
},
/**
* Get the node's image data if any (for canvas and img nodes).
* Returns an imageData object with the actual data being a LongStringActor
* and a size json object.
* The image data is transmitted as a base64 encoded png data-uri.
* The method rejects if the node isn't an image or if the image is missing
*
* Accepts a maxDim request parameter to resize images that are larger. This
* is important as the resizing occurs server-side so that image-data being
* transfered in the longstring back to the client will be that much smaller
*/
getImageDataFromURL: function (url, maxDim) {
let img = new this.window.Image();
img.src = url;
// imageToImageData waits for the image to load.
return imageToImageData(img, maxDim).then(imageData => {
return {
data: LongStringActor(this.conn, imageData.data),
size: imageData.size
};
});
},
/**
* Resolve a URL to its absolute form, in the scope of a given content window.
* @param {String} url.
* @param {NodeActor} node If provided, the owner window of this node will be
* used to resolve the URL. Otherwise, the top-level content window will be
* used instead.
* @return {String} url.
*/
resolveRelativeURL: function (url, node) {
let document = isNodeDead(node)
? this.window.document
: nodeDocument(node.rawNode);
if (!document) {
return url;
}
let baseURI = Services.io.newURI(document.location.href, null, null);
return Services.io.newURI(url, null, baseURI).spec;
},
/**
* Create an instance of the eye-dropper highlighter and store it on this._eyeDropper.
* Note that for now, a new instance is created every time to deal with page navigation.
*/
createEyeDropper: function () {
this.destroyEyeDropper();
this._highlighterEnv = new HighlighterEnvironment();
this._highlighterEnv.initFromTabActor(this.tabActor);
this._eyeDropper = new EyeDropper(this._highlighterEnv);
},
/**
* Destroy the current eye-dropper highlighter instance.
*/
destroyEyeDropper: function () {
if (this._eyeDropper) {
this.cancelPickColorFromPage();
this._eyeDropper.destroy();
this._eyeDropper = null;
this._highlighterEnv.destroy();
this._highlighterEnv = null;
}
},
/**
* Pick a color from the page using the eye-dropper. This method doesn't return anything
* but will cause events to be sent to the front when a color is picked or when the user
* cancels the picker.
* @param {Object} options
*/
pickColorFromPage: function (options) {
this.createEyeDropper();
this._eyeDropper.show(this.window.document.documentElement, options);
this._eyeDropper.once("selected", this._onColorPicked);
this._eyeDropper.once("canceled", this._onColorPickCanceled);
events.once(this.tabActor, "will-navigate", this.destroyEyeDropper);
},
/**
* After the pickColorFromPage method is called, the only way to dismiss the eye-dropper
* highlighter is for the user to click in the page and select a color. If you need to
* dismiss the eye-dropper programatically instead, use this method.
*/
cancelPickColorFromPage: function () {
if (this._eyeDropper) {
this._eyeDropper.hide();
this._eyeDropper.off("selected", this._onColorPicked);
this._eyeDropper.off("canceled", this._onColorPickCanceled);
events.off(this.tabActor, "will-navigate", this.destroyEyeDropper);
}
},
_onColorPicked: function (e, color) {
events.emit(this, "color-picked", color);
},
_onColorPickCanceled: function () {
events.emit(this, "color-pick-canceled");
}
});
// Exported for test purposes.
exports._documentWalker = DocumentWalker;
function nodeDocument(node) {
if (Cu.isDeadWrapper(node)) {
return null;
}
return node.ownerDocument ||
(node.nodeType == Ci.nsIDOMNode.DOCUMENT_NODE ? node : null);
}
function nodeDocshell(node) {
let doc = node ? nodeDocument(node) : null;
let win = doc ? doc.defaultView : null;
if (win) {
return win.QueryInterface(Ci.nsIInterfaceRequestor)
.getInterface(Ci.nsIDocShell);
}
return null;
}
function isNodeDead(node) {
return !node || !node.rawNode || Cu.isDeadWrapper(node.rawNode);
}
/**
* Wrapper for inDeepTreeWalker. Adds filtering to the traversal methods.
* See inDeepTreeWalker for more information about the methods.
*
* @param {DOMNode} node
* @param {Window} rootWin
* @param {Int} whatToShow See nodeFilterConstants / inIDeepTreeWalker for
* options.
* @param {Function} filter A custom filter function Taking in a DOMNode
* and returning an Int. See WalkerActor.nodeFilter for an example.
*/
function DocumentWalker(node, rootWin,
whatToShow = nodeFilterConstants.SHOW_ALL,
filter = standardTreeWalkerFilter) {
if (!rootWin.location) {
throw new Error("Got an invalid root window in DocumentWalker");
}
this.walker = Cc["@mozilla.org/inspector/deep-tree-walker;1"]
.createInstance(Ci.inIDeepTreeWalker);
this.walker.showAnonymousContent = true;
this.walker.showSubDocuments = true;
this.walker.showDocumentsAsNodes = true;
this.walker.init(rootWin.document, whatToShow);
this.filter = filter;
// Make sure that the walker knows about the initial node (which could
// be skipped due to a filter). Note that simply calling parentNode()
// causes currentNode to be updated.
this.walker.currentNode = node;
while (node &&
this.filter(node) === nodeFilterConstants.FILTER_SKIP) {
node = this.walker.parentNode();
}
}
DocumentWalker.prototype = {
get node() {
return this.walker.node;
},
get whatToShow() {
return this.walker.whatToShow;
},
get currentNode() {
return this.walker.currentNode;
},
set currentNode(val) {
this.walker.currentNode = val;
},
parentNode: function () {
return this.walker.parentNode();
},
nextNode: function () {
let node = this.walker.currentNode;
if (!node) {
return null;
}
let nextNode = this.walker.nextNode();
while (nextNode &&
this.filter(nextNode) === nodeFilterConstants.FILTER_SKIP) {
nextNode = this.walker.nextNode();
}
return nextNode;
},
firstChild: function () {
let node = this.walker.currentNode;
if (!node) {
return null;
}
let firstChild = this.walker.firstChild();
while (firstChild &&
this.filter(firstChild) === nodeFilterConstants.FILTER_SKIP) {
firstChild = this.walker.nextSibling();
}
return firstChild;
},
lastChild: function () {
let node = this.walker.currentNode;
if (!node) {
return null;
}
let lastChild = this.walker.lastChild();
while (lastChild &&
this.filter(lastChild) === nodeFilterConstants.FILTER_SKIP) {
lastChild = this.walker.previousSibling();
}
return lastChild;
},
previousSibling: function () {
let node = this.walker.previousSibling();
while (node && this.filter(node) === nodeFilterConstants.FILTER_SKIP) {
node = this.walker.previousSibling();
}
return node;
},
nextSibling: function () {
let node = this.walker.nextSibling();
while (node && this.filter(node) === nodeFilterConstants.FILTER_SKIP) {
node = this.walker.nextSibling();
}
return node;
}
};
function isInXULDocument(el) {
let doc = nodeDocument(el);
return doc &&
doc.documentElement &&
doc.documentElement.namespaceURI === XUL_NS;
}
/**
* This DeepTreeWalker filter skips whitespace text nodes and anonymous
* content with the exception of ::before and ::after and anonymous content
* in XUL document (needed to show all elements in the browser toolbox).
*/
function standardTreeWalkerFilter(node) {
// ::before and ::after are native anonymous content, but we always
// want to show them
if (node.nodeName === "_moz_generated_content_before" ||
node.nodeName === "_moz_generated_content_after") {
return nodeFilterConstants.FILTER_ACCEPT;
}
// Ignore empty whitespace text nodes that do not impact the layout.
if (isWhitespaceTextNode(node)) {
return nodeHasSize(node)
? nodeFilterConstants.FILTER_ACCEPT
: nodeFilterConstants.FILTER_SKIP;
}
// Ignore all native and XBL anonymous content inside a non-XUL document
if (!isInXULDocument(node) && (isXBLAnonymous(node) ||
isNativeAnonymous(node))) {
// Note: this will skip inspecting the contents of feedSubscribeLine since
// that's XUL content injected in an HTML document, but we need to because
// this also skips many other elements that need to be skipped - like form
// controls, scrollbars, video controls, etc (see bug 1187482).
return nodeFilterConstants.FILTER_SKIP;
}
return nodeFilterConstants.FILTER_ACCEPT;
}
/**
* This DeepTreeWalker filter is like standardTreeWalkerFilter except that
* it also includes all anonymous content (like internal form controls).
*/
function allAnonymousContentTreeWalkerFilter(node) {
// Ignore empty whitespace text nodes that do not impact the layout.
if (isWhitespaceTextNode(node)) {
return nodeHasSize(node)
? nodeFilterConstants.FILTER_ACCEPT
: nodeFilterConstants.FILTER_SKIP;
}
return nodeFilterConstants.FILTER_ACCEPT;
}
/**
* Is the given node a text node composed of whitespace only?
* @param {DOMNode} node
* @return {Boolean}
*/
function isWhitespaceTextNode(node) {
return node.nodeType == Ci.nsIDOMNode.TEXT_NODE && !/[^\s]/.exec(node.nodeValue);
}
/**
* Does the given node have non-0 width and height?
* @param {DOMNode} node
* @return {Boolean}
*/
function nodeHasSize(node) {
if (!node.getBoxQuads) {
return false;
}
let quads = node.getBoxQuads();
return quads.length && quads.some(quad => quad.bounds.width && quad.bounds.height);
}
/**
* Returns a promise that is settled once the given HTMLImageElement has
* finished loading.
*
* @param {HTMLImageElement} image - The image element.
* @param {Number} timeout - Maximum amount of time the image is allowed to load
* before the waiting is aborted. Ignored if flags.testing is set.
*
* @return {Promise} that is fulfilled once the image has loaded. If the image
* fails to load or the load takes too long, the promise is rejected.
*/
function ensureImageLoaded(image, timeout) {
let { HTMLImageElement } = image.ownerDocument.defaultView;
if (!(image instanceof HTMLImageElement)) {
return promise.reject("image must be an HTMLImageELement");
}
if (image.complete) {
// The image has already finished loading.
return promise.resolve();
}
// This image is still loading.
let onLoad = AsyncUtils.listenOnce(image, "load");
// Reject if loading fails.
let onError = AsyncUtils.listenOnce(image, "error").then(() => {
return promise.reject("Image '" + image.src + "' failed to load.");
});
// Don't timeout when testing. This is never settled.
let onAbort = new Promise(() => {});
if (!flags.testing) {
// Tests are not running. Reject the promise after given timeout.
onAbort = DevToolsUtils.waitForTime(timeout).then(() => {
return promise.reject("Image '" + image.src + "' took too long to load.");
});
}
// See which happens first.
return promise.race([onLoad, onError, onAbort]);
}
/**
* Given an <img> or <canvas> element, return the image data-uri. If @param node
* is an <img> element, the method waits a while for the image to load before
* the data is generated. If the image does not finish loading in a reasonable
* time (IMAGE_FETCHING_TIMEOUT milliseconds) the process aborts.
*
* @param {HTMLImageElement|HTMLCanvasElement} node - The <img> or <canvas>
* element, or Image() object. Other types cause the method to reject.
* @param {Number} maxDim - Optionally pass a maximum size you want the longest
* side of the image to be resized to before getting the image data.
* @return {Promise} A promise that is fulfilled with an object containing the
* data-uri and size-related information:
* { data: "...",
* size: {
* naturalWidth: 400,
* naturalHeight: 300,
* resized: true }
* }.
*
* If something goes wrong, the promise is rejected.
*/
var imageToImageData = Task.async(function* (node, maxDim) {
let { HTMLCanvasElement, HTMLImageElement } = node.ownerDocument.defaultView;
let isImg = node instanceof HTMLImageElement;
let isCanvas = node instanceof HTMLCanvasElement;
if (!isImg && !isCanvas) {
throw new Error("node is not a <canvas> or <img> element.");
}
if (isImg) {
// Ensure that the image is ready.
yield ensureImageLoaded(node, IMAGE_FETCHING_TIMEOUT);
}
// Get the image resize ratio if a maxDim was provided
let resizeRatio = 1;
let imgWidth = node.naturalWidth || node.width;
let imgHeight = node.naturalHeight || node.height;
let imgMax = Math.max(imgWidth, imgHeight);
if (maxDim && imgMax > maxDim) {
resizeRatio = maxDim / imgMax;
}
// Extract the image data
let imageData;
// The image may already be a data-uri, in which case, save ourselves the
// trouble of converting via the canvas.drawImage.toDataURL method, but only
// if the image doesn't need resizing
if (isImg && node.src.startsWith("data:") && resizeRatio === 1) {
imageData = node.src;
} else {
// Create a canvas to copy the rawNode into and get the imageData from
let canvas = node.ownerDocument.createElementNS(XHTML_NS, "canvas");
canvas.width = imgWidth * resizeRatio;
canvas.height = imgHeight * resizeRatio;
let ctx = canvas.getContext("2d");
// Copy the rawNode image or canvas in the new canvas and extract data
ctx.drawImage(node, 0, 0, canvas.width, canvas.height);
imageData = canvas.toDataURL("image/png");
}
return {
data: imageData,
size: {
naturalWidth: imgWidth,
naturalHeight: imgHeight,
resized: resizeRatio !== 1
}
};
});
loader.lazyGetter(this, "DOMUtils", function () {
return Cc["@mozilla.org/inspector/dom-utils;1"].getService(Ci.inIDOMUtils);
});