diff options
Diffstat (limited to 'devtools/server/actors/inspector.js')
-rw-r--r-- | devtools/server/actors/inspector.js | 3186 |
1 files changed, 3186 insertions, 0 deletions
diff --git a/devtools/server/actors/inspector.js b/devtools/server/actors/inspector.js new file mode 100644 index 000000000..20a227a40 --- /dev/null +++ b/devtools/server/actors/inspector.js @@ -0,0 +1,3186 @@ +/* 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); + }, + + /** + * 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); +}); |