summaryrefslogtreecommitdiffstats
path: root/devtools/server/actors/inspector
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/server/actors/inspector')
-rw-r--r--devtools/server/actors/inspector/constants.js17
-rw-r--r--devtools/server/actors/inspector/css-logic.js1646
-rw-r--r--devtools/server/actors/inspector/custom-element-watcher.js144
-rw-r--r--devtools/server/actors/inspector/document-walker.js196
-rw-r--r--devtools/server/actors/inspector/event-collector.js1062
-rw-r--r--devtools/server/actors/inspector/inspector.js355
-rw-r--r--devtools/server/actors/inspector/moz.build21
-rw-r--r--devtools/server/actors/inspector/node-picker.js435
-rw-r--r--devtools/server/actors/inspector/node.js838
-rw-r--r--devtools/server/actors/inspector/utils.js570
-rw-r--r--devtools/server/actors/inspector/walker.js2753
11 files changed, 8037 insertions, 0 deletions
diff --git a/devtools/server/actors/inspector/constants.js b/devtools/server/actors/inspector/constants.js
new file mode 100644
index 0000000000..c253c67b02
--- /dev/null
+++ b/devtools/server/actors/inspector/constants.js
@@ -0,0 +1,17 @@
+/* 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";
+
+/**
+ * Any event listener flagged with this symbol will not be considered when
+ * the EventCollector class enumerates listeners for nodes. For example:
+ *
+ * const someListener = () => {};
+ * someListener[EXCLUDED_LISTENER] = true;
+ * eventListenerService.addSystemEventListener(node, "event", someListener);
+ */
+const EXCLUDED_LISTENER = Symbol("event-collector-excluded-listener");
+
+exports.EXCLUDED_LISTENER = EXCLUDED_LISTENER;
diff --git a/devtools/server/actors/inspector/css-logic.js b/devtools/server/actors/inspector/css-logic.js
new file mode 100644
index 0000000000..c081145428
--- /dev/null
+++ b/devtools/server/actors/inspector/css-logic.js
@@ -0,0 +1,1646 @@
+/* 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/. */
+
+/*
+ * About the objects defined in this file:
+ * - CssLogic contains style information about a view context. It provides
+ * access to 2 sets of objects: Css[Sheet|Rule|Selector] provide access to
+ * information that does not change when the selected element changes while
+ * Css[Property|Selector]Info provide information that is dependent on the
+ * selected element.
+ * Its key methods are highlight(), getPropertyInfo() and forEachSheet(), etc
+ *
+ * - CssSheet provides a more useful API to a DOM CSSSheet for our purposes,
+ * including shortSource and href.
+ * - CssRule a more useful API to a DOM CSSRule including access to the group
+ * of CssSelectors that the rule provides properties for
+ * - CssSelector A single selector - i.e. not a selector group. In other words
+ * a CssSelector does not contain ','. This terminology is different from the
+ * standard DOM API, but more inline with the definition in the spec.
+ *
+ * - CssPropertyInfo contains style information for a single property for the
+ * highlighted element.
+ * - CssSelectorInfo is a wrapper around CssSelector, which adds sorting with
+ * reference to the selected element.
+ */
+
+"use strict";
+
+const nodeConstants = require("resource://devtools/shared/dom-node-constants.js");
+const {
+ getBindingElementAndPseudo,
+ getCSSStyleRules,
+ l10n,
+ hasVisitedState,
+ isAgentStylesheet,
+ isAuthorStylesheet,
+ isUserStylesheet,
+ shortSource,
+ FILTER,
+ STATUS,
+} = require("resource://devtools/shared/inspector/css-logic.js");
+
+const COMPAREMODE = {
+ BOOLEAN: "bool",
+ INTEGER: "int",
+};
+
+class CssLogic {
+ constructor() {
+ this._propertyInfos = {};
+ }
+
+ // Both setup by highlight().
+ viewedElement = null;
+ viewedDocument = null;
+
+ // The cache of the known sheets.
+ _sheets = null;
+
+ // Have the sheets been cached?
+ _sheetsCached = false;
+
+ // The total number of rules, in all stylesheets, after filtering.
+ _ruleCount = 0;
+
+ // The computed styles for the viewedElement.
+ _computedStyle = null;
+
+ // Source filter. Only display properties coming from the given source
+ _sourceFilter = FILTER.USER;
+
+ // Used for tracking unique CssSheet/CssRule/CssSelector objects, in a run of
+ // processMatchedSelectors().
+ _passId = 0;
+
+ // Used for tracking matched CssSelector objects.
+ _matchId = 0;
+
+ _matchedRules = null;
+ _matchedSelectors = null;
+
+ // Cached keyframes rules in all stylesheets
+ _keyframesRules = null;
+
+ /**
+ * Reset various properties
+ */
+ reset() {
+ this._propertyInfos = {};
+ this._ruleCount = 0;
+ this._sheetIndex = 0;
+ this._sheets = {};
+ this._sheetsCached = false;
+ this._matchedRules = null;
+ this._matchedSelectors = null;
+ this._keyframesRules = [];
+ }
+
+ /**
+ * Focus on a new element - remove the style caches.
+ *
+ * @param {Element} aViewedElement the element the user has highlighted
+ * in the Inspector.
+ */
+ highlight(viewedElement) {
+ if (!viewedElement) {
+ this.viewedElement = null;
+ this.viewedDocument = null;
+ this._computedStyle = null;
+ this.reset();
+ return;
+ }
+
+ if (viewedElement === this.viewedElement) {
+ return;
+ }
+
+ this.viewedElement = viewedElement;
+
+ const doc = this.viewedElement.ownerDocument;
+ if (doc != this.viewedDocument) {
+ // New document: clear/rebuild the cache.
+ this.viewedDocument = doc;
+
+ // Hunt down top level stylesheets, and cache them.
+ this._cacheSheets();
+ } else {
+ // Clear cached data in the CssPropertyInfo objects.
+ this._propertyInfos = {};
+ }
+
+ this._matchedRules = null;
+ this._matchedSelectors = null;
+ this._computedStyle = CssLogic.getComputedStyle(this.viewedElement);
+ }
+
+ /**
+ * Get the values of all the computed CSS properties for the highlighted
+ * element.
+ * @returns {object} The computed CSS properties for a selected element
+ */
+ get computedStyle() {
+ return this._computedStyle;
+ }
+
+ /**
+ * Get the source filter.
+ * @returns {string} The source filter being used.
+ */
+ get sourceFilter() {
+ return this._sourceFilter;
+ }
+
+ /**
+ * Source filter. Only display properties coming from the given source (web
+ * address). Note that in order to avoid information overload we DO NOT show
+ * unmatched system rules.
+ * @see FILTER.*
+ */
+ set sourceFilter(value) {
+ const oldValue = this._sourceFilter;
+ this._sourceFilter = value;
+
+ let ruleCount = 0;
+
+ // Update the CssSheet objects.
+ this.forEachSheet(function (sheet) {
+ if (sheet.authorSheet && sheet.sheetAllowed) {
+ ruleCount += sheet.ruleCount;
+ }
+ }, this);
+
+ this._ruleCount = ruleCount;
+
+ // Full update is needed because the this.processMatchedSelectors() method
+ // skips UA stylesheets if the filter does not allow such sheets.
+ const needFullUpdate = oldValue == FILTER.UA || value == FILTER.UA;
+
+ if (needFullUpdate) {
+ this._matchedRules = null;
+ this._matchedSelectors = null;
+ this._propertyInfos = {};
+ } else {
+ // Update the CssPropertyInfo objects.
+ for (const property in this._propertyInfos) {
+ this._propertyInfos[property].needRefilter = true;
+ }
+ }
+ }
+
+ /**
+ * Return a CssPropertyInfo data structure for the currently viewed element
+ * and the specified CSS property. If there is no currently viewed element we
+ * return an empty object.
+ *
+ * @param {string} property The CSS property to look for.
+ * @return {CssPropertyInfo} a CssPropertyInfo structure for the given
+ * property.
+ */
+ getPropertyInfo(property) {
+ if (!this.viewedElement) {
+ return {};
+ }
+
+ let info = this._propertyInfos[property];
+ if (!info) {
+ info = new CssPropertyInfo(this, property);
+ this._propertyInfos[property] = info;
+ }
+
+ return info;
+ }
+
+ /**
+ * Cache all the stylesheets in the inspected document
+ * @private
+ */
+ _cacheSheets() {
+ this._passId++;
+ this.reset();
+
+ // styleSheets isn't an array, but forEach can work on it anyway
+ const styleSheets = InspectorUtils.getAllStyleSheets(
+ this.viewedDocument,
+ true
+ );
+ Array.prototype.forEach.call(styleSheets, this._cacheSheet, this);
+
+ this._sheetsCached = true;
+ }
+
+ /**
+ * Cache a stylesheet if it falls within the requirements: if it's enabled,
+ * and if the @media is allowed. This method also walks through the stylesheet
+ * cssRules to find @imported rules, to cache the stylesheets of those rules
+ * as well. In addition, the @keyframes rules in the stylesheet are cached.
+ *
+ * @private
+ * @param {CSSStyleSheet} domSheet the CSSStyleSheet object to cache.
+ */
+ _cacheSheet(domSheet) {
+ if (domSheet.disabled) {
+ return;
+ }
+
+ // Only work with stylesheets that have their media allowed.
+ if (!this.mediaMatches(domSheet)) {
+ return;
+ }
+
+ // Cache the sheet.
+ const cssSheet = this.getSheet(domSheet, this._sheetIndex++);
+ if (cssSheet._passId != this._passId) {
+ cssSheet._passId = this._passId;
+
+ // Find import and keyframes rules.
+ for (const aDomRule of cssSheet.getCssRules()) {
+ if (
+ aDomRule.type == CSSRule.IMPORT_RULE &&
+ aDomRule.styleSheet &&
+ this.mediaMatches(aDomRule)
+ ) {
+ this._cacheSheet(aDomRule.styleSheet);
+ } else if (aDomRule.type == CSSRule.KEYFRAMES_RULE) {
+ this._keyframesRules.push(aDomRule);
+ }
+ }
+ }
+ }
+
+ /**
+ * Retrieve the list of stylesheets in the document.
+ *
+ * @return {array} the list of stylesheets in the document.
+ */
+ get sheets() {
+ if (!this._sheetsCached) {
+ this._cacheSheets();
+ }
+
+ const sheets = [];
+ this.forEachSheet(function (sheet) {
+ if (sheet.authorSheet) {
+ sheets.push(sheet);
+ }
+ }, this);
+
+ return sheets;
+ }
+
+ /**
+ * Retrieve the list of keyframes rules in the document.
+ *
+ * @ return {array} the list of keyframes rules in the document.
+ */
+ get keyframesRules() {
+ if (!this._sheetsCached) {
+ this._cacheSheets();
+ }
+ return this._keyframesRules;
+ }
+
+ /**
+ * Retrieve a CssSheet object for a given a CSSStyleSheet object. If the
+ * stylesheet is already cached, you get the existing CssSheet object,
+ * otherwise the new CSSStyleSheet object is cached.
+ *
+ * @param {CSSStyleSheet} domSheet the CSSStyleSheet object you want.
+ * @param {number} index the index, within the document, of the stylesheet.
+ *
+ * @return {CssSheet} the CssSheet object for the given CSSStyleSheet object.
+ */
+ getSheet(domSheet, index) {
+ let cacheId = "";
+
+ if (domSheet.href) {
+ cacheId = domSheet.href;
+ } else if (domSheet.associatedDocument) {
+ cacheId = domSheet.associatedDocument.location;
+ }
+
+ let sheet = null;
+ let sheetFound = false;
+
+ if (cacheId in this._sheets) {
+ for (sheet of this._sheets[cacheId]) {
+ if (sheet.domSheet === domSheet) {
+ if (index != -1) {
+ sheet.index = index;
+ }
+ sheetFound = true;
+ break;
+ }
+ }
+ }
+
+ if (!sheetFound) {
+ if (!(cacheId in this._sheets)) {
+ this._sheets[cacheId] = [];
+ }
+
+ sheet = new CssSheet(this, domSheet, index);
+ if (sheet.sheetAllowed && sheet.authorSheet) {
+ this._ruleCount += sheet.ruleCount;
+ }
+
+ this._sheets[cacheId].push(sheet);
+ }
+
+ return sheet;
+ }
+
+ /**
+ * Process each cached stylesheet in the document using your callback.
+ *
+ * @param {function} callback the function you want executed for each of the
+ * CssSheet objects cached.
+ * @param {object} scope the scope you want for the callback function. scope
+ * will be the this object when callback executes.
+ */
+ forEachSheet(callback, scope) {
+ for (const cacheId in this._sheets) {
+ const sheets = this._sheets[cacheId];
+ for (let i = 0; i < sheets.length; i++) {
+ // We take this as an opportunity to clean dead sheets
+ try {
+ const sheet = sheets[i];
+ // If accessing domSheet raises an exception, then the style
+ // sheet is a dead object.
+ sheet.domSheet;
+ callback.call(scope, sheet, i, sheets);
+ } catch (e) {
+ sheets.splice(i, 1);
+ i--;
+ }
+ }
+ }
+ }
+
+ /**
+
+ /**
+ * Get the number CSSRule objects in the document, counted from all of
+ * the stylesheets. System sheets are excluded. If a filter is active, this
+ * tells only the number of CSSRule objects inside the selected
+ * CSSStyleSheet.
+ *
+ * WARNING: This only provides an estimate of the rule count, and the results
+ * could change at a later date. Todo remove this
+ *
+ * @return {number} the number of CSSRule (all rules).
+ */
+ get ruleCount() {
+ if (!this._sheetsCached) {
+ this._cacheSheets();
+ }
+
+ return this._ruleCount;
+ }
+
+ /**
+ * Process the CssSelector objects that match the highlighted element and its
+ * parent elements. scope.callback() is executed for each CssSelector
+ * object, being passed the CssSelector object and the match status.
+ *
+ * This method also includes all of the element.style properties, for each
+ * highlighted element parent and for the highlighted element itself.
+ *
+ * Note that the matched selectors are cached, such that next time your
+ * callback is invoked for the cached list of CssSelector objects.
+ *
+ * @param {function} callback the function you want to execute for each of
+ * the matched selectors.
+ * @param {object} scope the scope you want for the callback function. scope
+ * will be the this object when callback executes.
+ */
+ processMatchedSelectors(callback, scope) {
+ if (this._matchedSelectors) {
+ if (callback) {
+ this._passId++;
+ this._matchedSelectors.forEach(function (value) {
+ callback.call(scope, value[0], value[1]);
+ value[0].cssRule._passId = this._passId;
+ }, this);
+ }
+ return;
+ }
+
+ if (!this._matchedRules) {
+ this._buildMatchedRules();
+ }
+
+ this._matchedSelectors = [];
+ this._passId++;
+
+ for (const matchedRule of this._matchedRules) {
+ const [rule, status, distance] = matchedRule;
+
+ rule.selectors.forEach(function (selector) {
+ if (
+ selector._matchId !== this._matchId &&
+ (selector.inlineStyle ||
+ this.selectorMatchesElement(rule.domRule, selector.selectorIndex))
+ ) {
+ selector._matchId = this._matchId;
+ this._matchedSelectors.push([selector, status, distance]);
+ if (callback) {
+ callback.call(scope, selector, status, distance);
+ }
+ }
+ }, this);
+
+ rule._passId = this._passId;
+ }
+ }
+
+ /**
+ * Check if the given selector matches the highlighted element or any of its
+ * parents.
+ *
+ * @private
+ * @param {DOMRule} domRule
+ * The DOM Rule containing the selector.
+ * @param {Number} idx
+ * The index of the selector within the DOMRule.
+ * @return {boolean}
+ * true if the given selector matches the highlighted element or any
+ * of its parents, otherwise false is returned.
+ */
+ selectorMatchesElement(domRule, idx) {
+ let element = this.viewedElement;
+ do {
+ if (InspectorUtils.selectorMatchesElement(element, domRule, idx)) {
+ return true;
+ }
+ } while (
+ (element = element.parentNode) &&
+ element.nodeType === nodeConstants.ELEMENT_NODE
+ );
+
+ return false;
+ }
+
+ /**
+ * Check if the highlighted element or it's parents have matched selectors.
+ *
+ * @param {array} aProperties The list of properties you want to check if they
+ * have matched selectors or not.
+ * @return {object} An object that tells for each property if it has matched
+ * selectors or not. Object keys are property names and values are booleans.
+ */
+ hasMatchedSelectors(properties) {
+ if (!this._matchedRules) {
+ this._buildMatchedRules();
+ }
+
+ const result = {};
+
+ this._matchedRules.some(function (value) {
+ const rule = value[0];
+ const status = value[1];
+ properties = properties.filter(property => {
+ // We just need to find if a rule has this property while it matches
+ // the viewedElement (or its parents).
+ if (
+ rule.getPropertyValue(property) &&
+ (status == STATUS.MATCHED ||
+ (status == STATUS.PARENT_MATCH &&
+ InspectorUtils.isInheritedProperty(property)))
+ ) {
+ result[property] = true;
+ return false;
+ }
+ // Keep the property for the next rule.
+ return true;
+ });
+ return !properties.length;
+ }, this);
+
+ return result;
+ }
+
+ /**
+ * Build the array of matched rules for the currently highlighted element.
+ * The array will hold rules that match the viewedElement and its parents.
+ *
+ * @private
+ */
+ _buildMatchedRules() {
+ let domRules;
+ let element = this.viewedElement;
+ const filter = this.sourceFilter;
+ let sheetIndex = 0;
+
+ // distance is used to tell us how close an ancestor is to an element e.g.
+ // 0: The rule is directly applied to the current element.
+ // -1: The rule is inherited from the current element's first parent.
+ // -2: The rule is inherited from the current element's second parent.
+ // etc.
+ let distance = 0;
+
+ this._matchId++;
+ this._passId++;
+ this._matchedRules = [];
+
+ if (!element) {
+ return;
+ }
+
+ do {
+ const status =
+ this.viewedElement === element ? STATUS.MATCHED : STATUS.PARENT_MATCH;
+
+ try {
+ domRules = getCSSStyleRules(element);
+ } catch (ex) {
+ console.log("CL__buildMatchedRules error: " + ex);
+ continue;
+ }
+
+ // getCSSStyleRules can return null with a shadow DOM element.
+ for (const domRule of domRules || []) {
+ if (domRule.type !== CSSRule.STYLE_RULE) {
+ continue;
+ }
+
+ const sheet = this.getSheet(domRule.parentStyleSheet, -1);
+ if (sheet._passId !== this._passId) {
+ sheet.index = sheetIndex++;
+ sheet._passId = this._passId;
+ }
+
+ if (filter === FILTER.USER && !sheet.authorSheet) {
+ continue;
+ }
+
+ const rule = sheet.getRule(domRule);
+ if (rule._passId === this._passId) {
+ continue;
+ }
+
+ rule._matchId = this._matchId;
+ rule._passId = this._passId;
+ this._matchedRules.push([rule, status, distance]);
+ }
+
+ // Add element.style information.
+ if (element.style && element.style.length) {
+ const rule = new CssRule(null, { style: element.style }, element);
+ rule._matchId = this._matchId;
+ rule._passId = this._passId;
+ this._matchedRules.push([rule, status, distance]);
+ }
+
+ distance--;
+ } while (
+ (element = element.parentNode) &&
+ element.nodeType === nodeConstants.ELEMENT_NODE
+ );
+ }
+
+ /**
+ * Tells if the given DOM CSS object matches the current view media.
+ *
+ * @param {object} domObject The DOM CSS object to check.
+ * @return {boolean} True if the DOM CSS object matches the current view
+ * media, or false otherwise.
+ */
+ mediaMatches(domObject) {
+ const mediaText = domObject.media.mediaText;
+ return (
+ !mediaText ||
+ this.viewedDocument.defaultView.matchMedia(mediaText).matches
+ );
+ }
+}
+
+/**
+ * If the element has an id, return '#id'. Otherwise return 'tagname[n]' where
+ * n is the index of this element in its siblings.
+ * <p>A technically more 'correct' output from the no-id case might be:
+ * 'tagname:nth-of-type(n)' however this is unlikely to be more understood
+ * and it is longer.
+ *
+ * @param {Element} element the element for which you want the short name.
+ * @return {string} the string to be displayed for element.
+ */
+CssLogic.getShortName = function (element) {
+ if (!element) {
+ return "null";
+ }
+ if (element.id) {
+ return "#" + element.id;
+ }
+ let priorSiblings = 0;
+ let temp = element;
+ while ((temp = temp.previousElementSibling)) {
+ priorSiblings++;
+ }
+ return element.tagName + "[" + priorSiblings + "]";
+};
+
+/**
+ * Get a string list of selectors for a given DOMRule.
+ *
+ * @param {DOMRule} domRule
+ * The DOMRule to parse.
+ * @return {Array}
+ * An array of string selectors.
+ */
+CssLogic.getSelectors = function (domRule) {
+ if (domRule.type !== CSSRule.STYLE_RULE) {
+ // Return empty array since InspectorUtils.getSelectorCount() assumes
+ // only STYLE_RULE type.
+ return [];
+ }
+
+ const selectors = [];
+
+ const len = InspectorUtils.getSelectorCount(domRule);
+ for (let i = 0; i < len; i++) {
+ const text = InspectorUtils.getSelectorText(domRule, i);
+ selectors.push(text);
+ }
+ return selectors;
+};
+
+/**
+ * Given a node, check to see if it is a ::before or ::after element.
+ * If so, return the node that is accessible from within the document
+ * (the parent of the anonymous node), along with which pseudo element
+ * it was. Otherwise, return the node itself.
+ *
+ * @returns {Object}
+ * - {DOMNode} node The non-anonymous node
+ * - {string} pseudo One of ':marker', ':before', ':after', or null.
+ */
+CssLogic.getBindingElementAndPseudo = getBindingElementAndPseudo;
+
+/**
+ * Get the computed style on a node. Automatically handles reading
+ * computed styles on a ::before/::after element by reading on the
+ * parent node with the proper pseudo argument.
+ *
+ * @param {Node}
+ * @returns {CSSStyleDeclaration}
+ */
+CssLogic.getComputedStyle = function (node) {
+ if (
+ !node ||
+ Cu.isDeadWrapper(node) ||
+ node.nodeType !== nodeConstants.ELEMENT_NODE ||
+ !node.ownerGlobal
+ ) {
+ return null;
+ }
+
+ const { bindingElement, pseudo } = CssLogic.getBindingElementAndPseudo(node);
+
+ // For reasons that still escape us, pseudo-elements can sometimes be "unattached" (i.e.
+ // not have a parentNode defined). This seems to happen when a page is reloaded while
+ // the inspector is open. Bailing out here ensures that the inspector does not fail at
+ // presenting DOM nodes and CSS styles when this happens. This is a temporary measure.
+ // See bug 1506792.
+ if (!bindingElement) {
+ return null;
+ }
+
+ return node.ownerGlobal.getComputedStyle(bindingElement, pseudo);
+};
+
+/**
+ * Get a source for a stylesheet, taking into account embedded stylesheets
+ * for which we need to use document.defaultView.location.href rather than
+ * sheet.href
+ *
+ * @param {CSSStyleSheet} sheet the DOM object for the style sheet.
+ * @return {string} the address of the stylesheet.
+ */
+CssLogic.href = function (sheet) {
+ return sheet.href || sheet.associatedDocument.location;
+};
+
+/**
+ * Returns true if the given node has visited state.
+ */
+CssLogic.hasVisitedState = hasVisitedState;
+
+class CssSheet {
+ /**
+ * A safe way to access cached bits of information about a stylesheet.
+ *
+ * @constructor
+ * @param {CssLogic} cssLogic pointer to the CssLogic instance working with
+ * this CssSheet object.
+ * @param {CSSStyleSheet} domSheet reference to a DOM CSSStyleSheet object.
+ * @param {number} index tells the index/position of the stylesheet within the
+ * main document.
+ */
+ constructor(cssLogic, domSheet, index) {
+ this._cssLogic = cssLogic;
+ this.domSheet = domSheet;
+ this.index = this.authorSheet ? index : -100 * index;
+
+ // Cache of the sheets href. Cached by the getter.
+ this._href = null;
+ // Short version of href for use in select boxes etc. Cached by getter.
+ this._shortSource = null;
+
+ // null for uncached.
+ this._sheetAllowed = null;
+
+ // Cached CssRules from the given stylesheet.
+ this._rules = {};
+
+ this._ruleCount = -1;
+ }
+
+ _passId = null;
+ _agentSheet = null;
+ _authorSheet = null;
+ _userSheet = null;
+
+ /**
+ * Check if the stylesheet is an agent stylesheet (provided by the browser).
+ *
+ * @return {boolean} true if this is an agent stylesheet, false otherwise.
+ */
+ get agentSheet() {
+ if (this._agentSheet === null) {
+ this._agentSheet = isAgentStylesheet(this.domSheet);
+ }
+ return this._agentSheet;
+ }
+
+ /**
+ * Check if the stylesheet is an author stylesheet (provided by the content page).
+ *
+ * @return {boolean} true if this is an author stylesheet, false otherwise.
+ */
+ get authorSheet() {
+ if (this._authorSheet === null) {
+ this._authorSheet = isAuthorStylesheet(this.domSheet);
+ }
+ return this._authorSheet;
+ }
+
+ /**
+ * Check if the stylesheet is a user stylesheet (provided by userChrome.css or
+ * userContent.css).
+ *
+ * @return {boolean} true if this is a user stylesheet, false otherwise.
+ */
+ get userSheet() {
+ if (this._userSheet === null) {
+ this._userSheet = isUserStylesheet(this.domSheet);
+ }
+ return this._userSheet;
+ }
+
+ /**
+ * Check if the stylesheet is disabled or not.
+ * @return {boolean} true if this stylesheet is disabled, or false otherwise.
+ */
+ get disabled() {
+ return this.domSheet.disabled;
+ }
+
+ /**
+ * Get a source for a stylesheet, using CssLogic.href
+ *
+ * @return {string} the address of the stylesheet.
+ */
+ get href() {
+ if (this._href) {
+ return this._href;
+ }
+
+ this._href = CssLogic.href(this.domSheet);
+ return this._href;
+ }
+
+ /**
+ * Create a shorthand version of the href of a stylesheet.
+ *
+ * @return {string} the shorthand source of the stylesheet.
+ */
+ get shortSource() {
+ if (this._shortSource) {
+ return this._shortSource;
+ }
+
+ this._shortSource = shortSource(this.domSheet);
+ return this._shortSource;
+ }
+
+ /**
+ * Tells if the sheet is allowed or not by the current CssLogic.sourceFilter.
+ *
+ * @return {boolean} true if the stylesheet is allowed by the sourceFilter, or
+ * false otherwise.
+ */
+ get sheetAllowed() {
+ if (this._sheetAllowed !== null) {
+ return this._sheetAllowed;
+ }
+
+ this._sheetAllowed = true;
+
+ const filter = this._cssLogic.sourceFilter;
+ if (filter === FILTER.USER && !this.authorSheet) {
+ this._sheetAllowed = false;
+ }
+ if (filter !== FILTER.USER && filter !== FILTER.UA) {
+ this._sheetAllowed = filter === this.href;
+ }
+
+ return this._sheetAllowed;
+ }
+
+ /**
+ * Retrieve the number of rules in this stylesheet.
+ *
+ * @return {number} the number of CSSRule objects in this stylesheet.
+ */
+ get ruleCount() {
+ try {
+ return this._ruleCount > -1 ? this._ruleCount : this.getCssRules().length;
+ } catch (e) {
+ return 0;
+ }
+ }
+
+ /**
+ * Retrieve the array of css rules for this stylesheet.
+ *
+ * Accessing cssRules on a stylesheet that is not completely loaded can throw a
+ * DOMException (Bug 625013). This wrapper will return an empty array instead.
+ *
+ * @return {Array} array of css rules.
+ **/
+ getCssRules() {
+ try {
+ return this.domSheet.cssRules;
+ } catch (e) {
+ return [];
+ }
+ }
+
+ /**
+ * Retrieve a CssRule object for the given CSSStyleRule. The CssRule object is
+ * cached, such that subsequent retrievals return the same CssRule object for
+ * the same CSSStyleRule object.
+ *
+ * @param {CSSStyleRule} aDomRule the CSSStyleRule object for which you want a
+ * CssRule object.
+ * @return {CssRule} the cached CssRule object for the given CSSStyleRule
+ * object.
+ */
+ getRule(domRule) {
+ const cacheId = domRule.type + domRule.selectorText;
+
+ let rule = null;
+ let ruleFound = false;
+
+ if (cacheId in this._rules) {
+ for (rule of this._rules[cacheId]) {
+ if (rule.domRule === domRule) {
+ ruleFound = true;
+ break;
+ }
+ }
+ }
+
+ if (!ruleFound) {
+ if (!(cacheId in this._rules)) {
+ this._rules[cacheId] = [];
+ }
+
+ rule = new CssRule(this, domRule);
+ this._rules[cacheId].push(rule);
+ }
+
+ return rule;
+ }
+
+ toString() {
+ return "CssSheet[" + this.shortSource + "]";
+ }
+}
+
+class CssRule {
+ /**
+ * Information about a single CSSStyleRule.
+ *
+ * @param {CSSSheet|null} cssSheet the CssSheet object of the stylesheet that
+ * holds the CSSStyleRule. If the rule comes from element.style, set this
+ * argument to null.
+ * @param {CSSStyleRule|object} domRule the DOM CSSStyleRule for which you want
+ * to cache data. If the rule comes from element.style, then provide
+ * an object of the form: {style: element.style}.
+ * @param {Element} [element] If the rule comes from element.style, then this
+ * argument must point to the element.
+ * @constructor
+ */
+ constructor(cssSheet, domRule, element) {
+ this._cssSheet = cssSheet;
+ this.domRule = domRule;
+
+ const parentRule = domRule.parentRule;
+ if (parentRule && parentRule.type == CSSRule.MEDIA_RULE) {
+ this.mediaText = parentRule.media.mediaText;
+ }
+
+ if (this._cssSheet) {
+ // parse domRule.selectorText on call to this.selectors
+ this._selectors = null;
+ this.line = InspectorUtils.getRelativeRuleLine(this.domRule);
+ this.column = InspectorUtils.getRuleColumn(this.domRule);
+ this.source = this._cssSheet.shortSource + ":" + this.line;
+ if (this.mediaText) {
+ this.source += " @media " + this.mediaText;
+ }
+ this.href = this._cssSheet.href;
+ this.authorRule = this._cssSheet.authorSheet;
+ this.userRule = this._cssSheet.userSheet;
+ this.agentRule = this._cssSheet.agentSheet;
+ } else if (element) {
+ this._selectors = [new CssSelector(this, "@element.style", 0)];
+ this.line = -1;
+ this.source = l10n("rule.sourceElement");
+ this.href = "#";
+ this.authorRule = true;
+ this.userRule = false;
+ this.agentRule = false;
+ this.sourceElement = element;
+ }
+ }
+
+ _passId = null;
+
+ mediaText = "";
+
+ get isMediaRule() {
+ return !!this.mediaText;
+ }
+
+ /**
+ * Check if the parent stylesheet is allowed by the CssLogic.sourceFilter.
+ *
+ * @return {boolean} true if the parent stylesheet is allowed by the current
+ * sourceFilter, or false otherwise.
+ */
+ get sheetAllowed() {
+ return this._cssSheet ? this._cssSheet.sheetAllowed : true;
+ }
+
+ /**
+ * Retrieve the parent stylesheet index/position in the viewed document.
+ *
+ * @return {number} the parent stylesheet index/position in the viewed
+ * document.
+ */
+ get sheetIndex() {
+ return this._cssSheet ? this._cssSheet.index : 0;
+ }
+
+ /**
+ * Retrieve the style property value from the current CSSStyleRule.
+ *
+ * @param {string} property the CSS property name for which you want the
+ * value.
+ * @return {string} the property value.
+ */
+ getPropertyValue(property) {
+ return this.domRule.style.getPropertyValue(property);
+ }
+
+ /**
+ * Retrieve the style property priority from the current CSSStyleRule.
+ *
+ * @param {string} property the CSS property name for which you want the
+ * priority.
+ * @return {string} the property priority.
+ */
+ getPropertyPriority(property) {
+ return this.domRule.style.getPropertyPriority(property);
+ }
+
+ /**
+ * Retrieve the list of CssSelector objects for each of the parsed selectors
+ * of the current CSSStyleRule.
+ *
+ * @return {array} the array hold the CssSelector objects.
+ */
+ get selectors() {
+ if (this._selectors) {
+ return this._selectors;
+ }
+
+ // Parse the CSSStyleRule.selectorText string.
+ this._selectors = [];
+
+ if (!this.domRule.selectorText) {
+ return this._selectors;
+ }
+
+ const selectors = CssLogic.getSelectors(this.domRule);
+
+ for (let i = 0, len = selectors.length; i < len; i++) {
+ this._selectors.push(new CssSelector(this, selectors[i], i));
+ }
+
+ return this._selectors;
+ }
+
+ toString() {
+ return "[CssRule " + this.domRule.selectorText + "]";
+ }
+}
+
+class CssSelector {
+ /**
+ * The CSS selector class allows us to document the ranking of various CSS
+ * selectors.
+ *
+ * @constructor
+ * @param {CssRule} cssRule the CssRule instance from where the selector comes.
+ * @param {string} selector The selector that we wish to investigate.
+ * @param {Number} index The index of the selector within it's rule.
+ */
+ constructor(cssRule, selector, index) {
+ this.cssRule = cssRule;
+ this.text = selector;
+ this.inlineStyle = this.text == "@element.style";
+ this._specificity = null;
+ this.selectorIndex = index;
+ }
+
+ _matchId = null;
+
+ /**
+ * Retrieve the CssSelector source, which is the source of the CssSheet owning
+ * the selector.
+ *
+ * @return {string} the selector source.
+ */
+ get source() {
+ return this.cssRule.source;
+ }
+
+ /**
+ * Retrieve the CssSelector source element, which is the source of the CssRule
+ * owning the selector. This is only available when the CssSelector comes from
+ * an element.style.
+ *
+ * @return {string} the source element selector.
+ */
+ get sourceElement() {
+ return this.cssRule.sourceElement;
+ }
+
+ /**
+ * Retrieve the address of the CssSelector. This points to the address of the
+ * CssSheet owning this selector.
+ *
+ * @return {string} the address of the CssSelector.
+ */
+ get href() {
+ return this.cssRule.href;
+ }
+
+ /**
+ * Check if the selector comes from an agent stylesheet (provided by the browser).
+ *
+ * @return {boolean} true if this is an agent stylesheet, false otherwise.
+ */
+ get agentRule() {
+ return this.cssRule.agentRule;
+ }
+
+ /**
+ * Check if the selector comes from an author stylesheet (provided by the content page).
+ *
+ * @return {boolean} true if this is an author stylesheet, false otherwise.
+ */
+ get authorRule() {
+ return this.cssRule.authorRule;
+ }
+
+ /**
+ * Check if the selector comes from a user stylesheet (provided by userChrome.css or
+ * userContent.css).
+ *
+ * @return {boolean} true if this is a user stylesheet, false otherwise.
+ */
+ get userRule() {
+ return this.cssRule.userRule;
+ }
+
+ /**
+ * Check if the parent stylesheet is allowed by the CssLogic.sourceFilter.
+ *
+ * @return {boolean} true if the parent stylesheet is allowed by the current
+ * sourceFilter, or false otherwise.
+ */
+ get sheetAllowed() {
+ return this.cssRule.sheetAllowed;
+ }
+
+ /**
+ * Retrieve the parent stylesheet index/position in the viewed document.
+ *
+ * @return {number} the parent stylesheet index/position in the viewed
+ * document.
+ */
+ get sheetIndex() {
+ return this.cssRule.sheetIndex;
+ }
+
+ /**
+ * Retrieve the line of the parent CSSStyleRule in the parent CSSStyleSheet.
+ *
+ * @return {number} the line of the parent CSSStyleRule in the parent
+ * stylesheet.
+ */
+ get ruleLine() {
+ return this.cssRule.line;
+ }
+
+ /**
+ * Retrieve the column of the parent CSSStyleRule in the parent CSSStyleSheet.
+ *
+ * @return {number} the column of the parent CSSStyleRule in the parent
+ * stylesheet.
+ */
+ get ruleColumn() {
+ return this.cssRule.column;
+ }
+
+ /**
+ * Retrieve specificity information for the current selector.
+ *
+ * @see http://www.w3.org/TR/css3-selectors/#specificity
+ * @see http://www.w3.org/TR/CSS2/selector.html
+ *
+ * @return {Number} The selector's specificity.
+ */
+ get specificity() {
+ if (this.inlineStyle) {
+ // We can't ask specificity from DOMUtils as element styles don't provide
+ // CSSStyleRule interface DOMUtils expect. However, specificity of element
+ // style is constant, 1,0,0,0 or 0x40000000, just return the constant
+ // directly. @see http://www.w3.org/TR/CSS2/cascade.html#specificity
+ return 0x40000000;
+ }
+
+ if (typeof this._specificity !== "number") {
+ this._specificity = InspectorUtils.getSpecificity(
+ this.cssRule.domRule,
+ this.selectorIndex
+ );
+ }
+
+ return this._specificity;
+ }
+
+ toString() {
+ return this.text;
+ }
+}
+
+class CssPropertyInfo {
+ /**
+ * A cache of information about the matched rules, selectors and values attached
+ * to a CSS property, for the highlighted element.
+ *
+ * The heart of the CssPropertyInfo object is the _findMatchedSelectors()
+ * method. This are invoked when the PropertyView tries to access the
+ * .matchedSelectors array.
+ * Results are cached, for later reuse.
+ *
+ * @param {CssLogic} cssLogic Reference to the parent CssLogic instance
+ * @param {string} property The CSS property we are gathering information for
+ * @constructor
+ */
+ constructor(cssLogic, property) {
+ this._cssLogic = cssLogic;
+ this.property = property;
+ this._value = "";
+
+ // An array holding CssSelectorInfo objects for each of the matched selectors
+ // that are inside a CSS rule. Only rules that hold the this.property are
+ // counted. This includes rules that come from filtered stylesheets (those
+ // that have sheetAllowed = false).
+ this._matchedSelectors = null;
+ }
+
+ /**
+ * Retrieve the computed style value for the current property, for the
+ * highlighted element.
+ *
+ * @return {string} the computed style value for the current property, for the
+ * highlighted element.
+ */
+ get value() {
+ if (!this._value && this._cssLogic.computedStyle) {
+ try {
+ this._value = this._cssLogic.computedStyle.getPropertyValue(
+ this.property
+ );
+ } catch (ex) {
+ console.log("Error reading computed style for " + this.property);
+ console.log(ex);
+ }
+ }
+ return this._value;
+ }
+
+ /**
+ * Retrieve the array holding CssSelectorInfo objects for each of the matched
+ * selectors, from each of the matched rules. Only selectors coming from
+ * allowed stylesheets are included in the array.
+ *
+ * @return {array} the list of CssSelectorInfo objects of selectors that match
+ * the highlighted element and its parents.
+ */
+ get matchedSelectors() {
+ if (!this._matchedSelectors) {
+ this._findMatchedSelectors();
+ } else if (this.needRefilter) {
+ this._refilterSelectors();
+ }
+
+ return this._matchedSelectors;
+ }
+
+ /**
+ * Find the selectors that match the highlighted element and its parents.
+ * Uses CssLogic.processMatchedSelectors() to find the matched selectors,
+ * passing in a reference to CssPropertyInfo._processMatchedSelector() to
+ * create CssSelectorInfo objects, which we then sort
+ * @private
+ */
+ _findMatchedSelectors() {
+ this._matchedSelectors = [];
+ this.needRefilter = false;
+
+ this._cssLogic.processMatchedSelectors(this._processMatchedSelector, this);
+
+ // Sort the selectors by how well they match the given element.
+ this._matchedSelectors.sort(function (selectorInfo1, selectorInfo2) {
+ return selectorInfo1.compareTo(selectorInfo2);
+ });
+
+ // Now we know which of the matches is best, we can mark it BEST_MATCH.
+ if (
+ this._matchedSelectors.length &&
+ this._matchedSelectors[0].status > STATUS.UNMATCHED
+ ) {
+ this._matchedSelectors[0].status = STATUS.BEST;
+ }
+ }
+
+ /**
+ * Process a matched CssSelector object.
+ *
+ * @private
+ * @param {CssSelector} selector the matched CssSelector object.
+ * @param {STATUS} status the CssSelector match status.
+ */
+ _processMatchedSelector(selector, status, distance) {
+ const cssRule = selector.cssRule;
+ const value = cssRule.getPropertyValue(this.property);
+ if (
+ value &&
+ (status == STATUS.MATCHED ||
+ (status == STATUS.PARENT_MATCH &&
+ InspectorUtils.isInheritedProperty(this.property)))
+ ) {
+ const selectorInfo = new CssSelectorInfo(
+ selector,
+ this.property,
+ value,
+ status,
+ distance
+ );
+ this._matchedSelectors.push(selectorInfo);
+ }
+ }
+
+ /**
+ * Refilter the matched selectors array when the CssLogic.sourceFilter
+ * changes. This allows for quick filter changes.
+ * @private
+ */
+ _refilterSelectors() {
+ const passId = ++this._cssLogic._passId;
+
+ const iterator = function (selectorInfo) {
+ const cssRule = selectorInfo.selector.cssRule;
+ if (cssRule._passId != passId) {
+ cssRule._passId = passId;
+ }
+ };
+
+ if (this._matchedSelectors) {
+ this._matchedSelectors.forEach(iterator);
+ }
+
+ this.needRefilter = false;
+ }
+
+ toString() {
+ return "CssPropertyInfo[" + this.property + "]";
+ }
+}
+
+class CssSelectorInfo {
+ /**
+ * A class that holds information about a given CssSelector object.
+ *
+ * Instances of this class are given to CssHtmlTree in the array of matched
+ * selectors. Each such object represents a displayable row in the PropertyView
+ * objects. The information given by this object blends data coming from the
+ * CssSheet, CssRule and from the CssSelector that own this object.
+ *
+ * @param {CssSelector} selector The CssSelector object for which to
+ * present information.
+ * @param {string} property The property for which information should
+ * be retrieved.
+ * @param {string} value The property value from the CssRule that owns
+ * the selector.
+ * @param {STATUS} status The selector match status.
+ * @param {number} distance See CssLogic._buildMatchedRules for definition.
+ * @constructor
+ */
+ constructor(selector, property, value, status, distance) {
+ this.selector = selector;
+ this.property = property;
+ this.status = status;
+ this.distance = distance;
+ this.value = value;
+ const priority = this.selector.cssRule.getPropertyPriority(this.property);
+ this.important = priority === "important";
+ }
+
+ /**
+ * Retrieve the CssSelector source, which is the source of the CssSheet owning
+ * the selector.
+ *
+ * @return {string} the selector source.
+ */
+ get source() {
+ return this.selector.source;
+ }
+
+ /**
+ * Retrieve the CssSelector source element, which is the source of the CssRule
+ * owning the selector. This is only available when the CssSelector comes from
+ * an element.style.
+ *
+ * @return {string} the source element selector.
+ */
+ get sourceElement() {
+ return this.selector.sourceElement;
+ }
+
+ /**
+ * Retrieve the address of the CssSelector. This points to the address of the
+ * CssSheet owning this selector.
+ *
+ * @return {string} the address of the CssSelector.
+ */
+ get href() {
+ return this.selector.href;
+ }
+
+ /**
+ * Check if the CssSelector comes from element.style or not.
+ *
+ * @return {boolean} true if the CssSelector comes from element.style, or
+ * false otherwise.
+ */
+ get inlineStyle() {
+ return this.selector.inlineStyle;
+ }
+
+ /**
+ * Retrieve specificity information for the current selector.
+ *
+ * @return {object} an object holding specificity information for the current
+ * selector.
+ */
+ get specificity() {
+ return this.selector.specificity;
+ }
+
+ /**
+ * Retrieve the parent stylesheet index/position in the viewed document.
+ *
+ * @return {number} the parent stylesheet index/position in the viewed
+ * document.
+ */
+ get sheetIndex() {
+ return this.selector.sheetIndex;
+ }
+
+ /**
+ * Check if the parent stylesheet is allowed by the CssLogic.sourceFilter.
+ *
+ * @return {boolean} true if the parent stylesheet is allowed by the current
+ * sourceFilter, or false otherwise.
+ */
+ get sheetAllowed() {
+ return this.selector.sheetAllowed;
+ }
+
+ /**
+ * Retrieve the line of the parent CSSStyleRule in the parent CSSStyleSheet.
+ *
+ * @return {number} the line of the parent CSSStyleRule in the parent
+ * stylesheet.
+ */
+ get ruleLine() {
+ return this.selector.ruleLine;
+ }
+
+ /**
+ * Retrieve the column of the parent CSSStyleRule in the parent CSSStyleSheet.
+ *
+ * @return {number} the column of the parent CSSStyleRule in the parent
+ * stylesheet.
+ */
+ get ruleColumn() {
+ return this.selector.ruleColumn;
+ }
+
+ /**
+ * Check if the selector comes from a browser-provided stylesheet.
+ *
+ * @return {boolean} true if the selector comes from a browser-provided
+ * stylesheet, or false otherwise.
+ */
+ get agentRule() {
+ return this.selector.agentRule;
+ }
+
+ /**
+ * Check if the selector comes from a webpage-provided stylesheet.
+ *
+ * @return {boolean} true if the selector comes from a webpage-provided
+ * stylesheet, or false otherwise.
+ */
+ get authorRule() {
+ return this.selector.authorRule;
+ }
+
+ /**
+ * Check if the selector comes from a user stylesheet (userChrome.css or
+ * userContent.css).
+ *
+ * @return {boolean} true if the selector comes from a webpage-provided
+ * stylesheet, or false otherwise.
+ */
+ get userRule() {
+ return this.selector.userRule;
+ }
+
+ /**
+ * Compare the current CssSelectorInfo instance to another instance, based on
+ * the CSS cascade (see https://www.w3.org/TR/css-cascade-4/#cascading):
+ *
+ * The cascade sorts declarations according to the following criteria, in
+ * descending order of priority:
+ *
+ * - Rules targetting a node directly must always win over rules targetting an
+ * ancestor.
+ *
+ * - Origin and Importance
+ * The origin of a declaration is based on where it comes from and its
+ * importance is whether or not it is declared !important (see below). For
+ * our purposes here we can safely ignore Transition declarations and
+ * Animation declarations.
+ * The precedence of the various origins is, in descending order:
+ * - Transition declarations (ignored)
+ * - Important user agent declarations (User-Agent & !important)
+ * - Important user declarations (User & !important)
+ * - Important author declarations (Author & !important)
+ * - Animation declarations (ignored)
+ * - Normal author declarations (Author, normal weight)
+ * - Normal user declarations (User, normal weight)
+ * - Normal user agent declarations (User-Agent, normal weight)
+ *
+ * - Specificity (see https://www.w3.org/TR/selectors/#specificity)
+ * - A selector’s specificity is calculated for a given element as follows:
+ * - count the number of ID selectors in the selector (= A)
+ * - count the number of class selectors, attributes selectors, and
+ * pseudo-classes in the selector (= B)
+ * - count the number of type selectors and pseudo-elements in the
+ * selector (= C)
+ * - ignore the universal selector
+ * - So "UL OL LI.red" has a specificity of a=0 b=1 c=3.
+ *
+ * - Order of Appearance
+ * - The last declaration in document order wins. For this purpose:
+ * - Declarations from imported style sheets are ordered as if their style
+ * sheets were substituted in place of the @import rule.
+ * - Declarations from style sheets independently linked by the
+ * originating document are treated as if they were concatenated in
+ * linking order, as determined by the host document language.
+ * - Declarations from style attributes are ordered according to the
+ * document order of the element the style attribute appears on, and are
+ * all placed after any style sheets.
+ * - We use three methods to calculate this:
+ * - Sheet index
+ * - Rule line
+ * - Rule column
+ *
+ * @param {CssSelectorInfo} that
+ * The instance to compare ourselves against.
+ * @return {Number}
+ * -1, 0, 1 depending on how that compares with this.
+ */
+ compareTo(that) {
+ let current = null;
+
+ // Rules targetting the node must always win over rules targetting a node's
+ // ancestor.
+ current = this.compare(that, "distance", COMPAREMODE.INTEGER);
+ if (current) {
+ return current;
+ }
+
+ if (this.important) {
+ // User-Agent & !important
+ // User & !important
+ // Author & !important
+ for (const propName of ["agentRule", "userRule", "authorRule"]) {
+ current = this.compare(that, propName, COMPAREMODE.BOOLEAN);
+ if (current) {
+ return current;
+ }
+ }
+ }
+
+ // Author, normal weight
+ // User, normal weight
+ // User-Agent, normal weight
+ for (const propName of ["authorRule", "userRule", "agentRule"]) {
+ current = this.compare(that, propName, COMPAREMODE.BOOLEAN);
+ if (current) {
+ return current;
+ }
+ }
+
+ // Specificity
+ // Sheet index
+ // Rule line
+ // Rule column
+ for (const propName of [
+ "specificity",
+ "sheetIndex",
+ "ruleLine",
+ "ruleColumn",
+ ]) {
+ current = this.compare(that, propName, COMPAREMODE.INTEGER);
+ if (current) {
+ return current;
+ }
+ }
+
+ // A rule has been compared against itself so return 0.
+ return 0;
+ }
+
+ compare(that, propertyName, type) {
+ switch (type) {
+ case COMPAREMODE.BOOLEAN:
+ if (this[propertyName] && !that[propertyName]) {
+ return -1;
+ }
+ if (!this[propertyName] && that[propertyName]) {
+ return 1;
+ }
+ break;
+ case COMPAREMODE.INTEGER:
+ if (this[propertyName] > that[propertyName]) {
+ return -1;
+ }
+ if (this[propertyName] < that[propertyName]) {
+ return 1;
+ }
+ break;
+ }
+ return 0;
+ }
+
+ toString() {
+ return this.selector + " -> " + this.value;
+ }
+}
+
+exports.CssLogic = CssLogic;
+exports.CssSelector = CssSelector;
diff --git a/devtools/server/actors/inspector/custom-element-watcher.js b/devtools/server/actors/inspector/custom-element-watcher.js
new file mode 100644
index 0000000000..8eb57fea40
--- /dev/null
+++ b/devtools/server/actors/inspector/custom-element-watcher.js
@@ -0,0 +1,144 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EventEmitter = require("resource://devtools/shared/event-emitter.js");
+
+/**
+ * The CustomElementWatcher can be used to be notified if a custom element definition
+ * is created for a node.
+ *
+ * When a custom element is defined for a monitored name, an "element-defined" event is
+ * fired with the following Object argument:
+ * - {String} name: name of the custom element defined
+ * - {Set} Set of impacted node actors
+ */
+class CustomElementWatcher extends EventEmitter {
+ constructor(chromeEventHandler) {
+ super();
+
+ this.chromeEventHandler = chromeEventHandler;
+ this._onCustomElementDefined = this._onCustomElementDefined.bind(this);
+ this.chromeEventHandler.addEventListener(
+ "customelementdefined",
+ this._onCustomElementDefined
+ );
+
+ /**
+ * Each window keeps its own custom element registry, all of them are watched
+ * separately. The struture of the watchedRegistries is as follows
+ *
+ * WeakMap(
+ * registry -> Map (
+ * name -> Set(NodeActors)
+ * )
+ * )
+ */
+ this.watchedRegistries = new WeakMap();
+ }
+
+ destroy() {
+ this.watchedRegistries = null;
+ this.chromeEventHandler.removeEventListener(
+ "customelementdefined",
+ this._onCustomElementDefined
+ );
+ }
+
+ /**
+ * Watch for custom element definitions matching the name of the provided NodeActor.
+ */
+ manageNode(nodeActor) {
+ if (!this._isValidNode(nodeActor)) {
+ return;
+ }
+
+ if (!this._shouldWatchDefinition(nodeActor)) {
+ return;
+ }
+
+ const registry = nodeActor.rawNode.ownerGlobal.customElements;
+ const registryMap = this._getMapForRegistry(registry);
+
+ const name = nodeActor.rawNode.localName;
+ const actorsSet = this._getActorsForName(name, registryMap);
+ actorsSet.add(nodeActor);
+ }
+
+ /**
+ * Stop watching the provided NodeActor.
+ */
+ unmanageNode(nodeActor) {
+ if (!this._isValidNode(nodeActor)) {
+ return;
+ }
+
+ const win = nodeActor.rawNode.ownerGlobal;
+ const registry = win.customElements;
+ const registryMap = this._getMapForRegistry(registry);
+ const name = nodeActor.rawNode.localName;
+ if (registryMap.has(name)) {
+ registryMap.get(name).delete(nodeActor);
+ }
+ }
+
+ /**
+ * Retrieve the map of name->nodeActors for a given CustomElementsRegistry.
+ * Will create the map if not created yet.
+ */
+ _getMapForRegistry(registry) {
+ if (!this.watchedRegistries.has(registry)) {
+ this.watchedRegistries.set(registry, new Map());
+ }
+ return this.watchedRegistries.get(registry);
+ }
+
+ /**
+ * Retrieve the set of nodeActors for a given name and registry.
+ * Will create the set if not created yet.
+ */
+ _getActorsForName(name, registryMap) {
+ if (!registryMap.has(name)) {
+ registryMap.set(name, new Set());
+ }
+ return registryMap.get(name);
+ }
+
+ _shouldWatchDefinition(nodeActor) {
+ const doc = nodeActor.rawNode.ownerDocument;
+ const namespaceURI = doc.documentElement.namespaceURI;
+ const name = nodeActor.rawNode.localName;
+ const isValidName = InspectorUtils.isCustomElementName(name, namespaceURI);
+
+ const customElements = doc.defaultView.customElements;
+ return isValidName && !customElements.get(name);
+ }
+
+ _onCustomElementDefined(event) {
+ const doc = event.target;
+ const registry = doc.defaultView.customElements;
+ const registryMap = this._getMapForRegistry(registry);
+
+ const name = event.detail;
+ const actors = this._getActorsForName(name, registryMap);
+ this.emit("element-defined", { name, actors });
+ registryMap.delete(name);
+ }
+
+ /**
+ * Some nodes (e.g. inside of <template> tags) don't have a documentElement or an
+ * ownerGlobal and can't be watched by this helper.
+ */
+ _isValidNode(nodeActor) {
+ const node = nodeActor.rawNode;
+ return (
+ !Cu.isDeadWrapper(node) &&
+ node.ownerGlobal &&
+ node.ownerDocument?.documentElement
+ );
+ }
+}
+
+exports.CustomElementWatcher = CustomElementWatcher;
diff --git a/devtools/server/actors/inspector/document-walker.js b/devtools/server/actors/inspector/document-walker.js
new file mode 100644
index 0000000000..7ced18ecd8
--- /dev/null
+++ b/devtools/server/actors/inspector/document-walker.js
@@ -0,0 +1,196 @@
+/* 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";
+
+loader.lazyRequireGetter(
+ this,
+ "nodeFilterConstants",
+ "resource://devtools/shared/dom-node-filter-constants.js"
+);
+loader.lazyRequireGetter(
+ this,
+ "standardTreeWalkerFilter",
+ "resource://devtools/server/actors/inspector/utils.js",
+ true
+);
+
+// SKIP_TO_* arguments are used with the DocumentWalker, driving the strategy to use if
+// the starting node is incompatible with the filter function of the walker.
+const SKIP_TO_PARENT = "SKIP_TO_PARENT";
+const SKIP_TO_SIBLING = "SKIP_TO_SIBLING";
+
+class DocumentWalker {
+ /**
+ * Wrapper for inDeepTreeWalker. Adds filtering to the traversal methods.
+ * See inDeepTreeWalker for more information about the methods.
+ *
+ * @param {DOMNode} node
+ * @param {Window} rootWin
+ * @param {Object}
+ * - {Function} filter
+ * A custom filter function Taking in a DOMNode and returning an Int. See
+ * WalkerActor.nodeFilter for an example.
+ * - {String} skipTo
+ * Either SKIP_TO_PARENT or SKIP_TO_SIBLING. If the provided node is not
+ * compatible with the filter function for this walker, try to find a compatible
+ * one either in the parents or in the siblings of the node.
+ * - {Boolean} showAnonymousContent
+ * Pass true to let the walker return and traverse anonymous content.
+ * When navigating host elements to which shadow DOM is attached, the light tree
+ * will be visible only to a walker with showAnonymousContent=false. The shadow
+ * tree will only be visible to a walker with showAnonymousContent=true.
+ */
+ constructor(
+ node,
+ rootWin,
+ {
+ filter = standardTreeWalkerFilter,
+ skipTo = SKIP_TO_PARENT,
+ showAnonymousContent = true,
+ } = {}
+ ) {
+ if (Cu.isDeadWrapper(rootWin) || !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 = showAnonymousContent;
+ this.walker.showSubDocuments = true;
+ this.walker.showDocumentsAsNodes = true;
+ this.walker.init(rootWin.document);
+ this.filter = filter;
+
+ // Make sure that the walker knows about the initial node (which could
+ // be skipped due to a filter).
+ this.walker.currentNode = this.getStartingNode(node, skipTo);
+ }
+
+ get currentNode() {
+ return this.walker.currentNode;
+ }
+ set currentNode(val) {
+ this.walker.currentNode = val;
+ }
+
+ parentNode() {
+ return this.walker.parentNode();
+ }
+
+ nextNode() {
+ const node = this.walker.currentNode;
+ if (!node) {
+ return null;
+ }
+
+ let nextNode = this.walker.nextNode();
+ while (nextNode && this.isSkippedNode(nextNode)) {
+ nextNode = this.walker.nextNode();
+ }
+
+ return nextNode;
+ }
+
+ firstChild() {
+ if (!this.walker.currentNode) {
+ return null;
+ }
+
+ let firstChild = this.walker.firstChild();
+ while (firstChild && this.isSkippedNode(firstChild)) {
+ firstChild = this.walker.nextSibling();
+ }
+
+ return firstChild;
+ }
+
+ lastChild() {
+ if (!this.walker.currentNode) {
+ return null;
+ }
+
+ let lastChild = this.walker.lastChild();
+ while (lastChild && this.isSkippedNode(lastChild)) {
+ lastChild = this.walker.previousSibling();
+ }
+
+ return lastChild;
+ }
+
+ previousSibling() {
+ let node = this.walker.previousSibling();
+ while (node && this.isSkippedNode(node)) {
+ node = this.walker.previousSibling();
+ }
+ return node;
+ }
+
+ nextSibling() {
+ let node = this.walker.nextSibling();
+ while (node && this.isSkippedNode(node)) {
+ node = this.walker.nextSibling();
+ }
+ return node;
+ }
+
+ getStartingNode(node, skipTo) {
+ // Keep a reference on the starting node in case we can't find a node compatible with
+ // the filter.
+ const startingNode = node;
+
+ if (skipTo === SKIP_TO_PARENT) {
+ while (node && this.isSkippedNode(node)) {
+ node = node.parentNode;
+ }
+ } else if (skipTo === SKIP_TO_SIBLING) {
+ node = this.getClosestAcceptedSibling(node);
+ }
+
+ return node || startingNode;
+ }
+
+ /**
+ * Loop on all of the provided node siblings until finding one that is compliant with
+ * the filter function.
+ */
+ getClosestAcceptedSibling(node) {
+ if (this.filter(node) === nodeFilterConstants.FILTER_ACCEPT) {
+ // node is already valid, return immediately.
+ return node;
+ }
+
+ // Loop on starting node siblings.
+ let previous = node;
+ let next = node;
+ while (previous || next) {
+ previous = previous?.previousSibling;
+ next = next?.nextSibling;
+
+ if (
+ previous &&
+ this.filter(previous) === nodeFilterConstants.FILTER_ACCEPT
+ ) {
+ // A valid node was found in the previous siblings of the node.
+ return previous;
+ }
+
+ if (next && this.filter(next) === nodeFilterConstants.FILTER_ACCEPT) {
+ // A valid node was found in the next siblings of the node.
+ return next;
+ }
+ }
+
+ return null;
+ }
+
+ isSkippedNode(node) {
+ return this.filter(node) === nodeFilterConstants.FILTER_SKIP;
+ }
+}
+
+exports.DocumentWalker = DocumentWalker;
+exports.SKIP_TO_PARENT = SKIP_TO_PARENT;
+exports.SKIP_TO_SIBLING = SKIP_TO_SIBLING;
diff --git a/devtools/server/actors/inspector/event-collector.js b/devtools/server/actors/inspector/event-collector.js
new file mode 100644
index 0000000000..3c307e50a0
--- /dev/null
+++ b/devtools/server/actors/inspector/event-collector.js
@@ -0,0 +1,1062 @@
+/* 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/. */
+
+// This file contains event collectors that are then used by developer tools in
+// order to find information about events affecting an HTML element.
+
+"use strict";
+
+const {
+ isAfterPseudoElement,
+ isBeforePseudoElement,
+ isMarkerPseudoElement,
+ isNativeAnonymous,
+} = require("resource://devtools/shared/layout/utils.js");
+const Debugger = require("Debugger");
+const {
+ EXCLUDED_LISTENER,
+} = require("resource://devtools/server/actors/inspector/constants.js");
+
+// eslint-disable-next-line
+const JQUERY_LIVE_REGEX =
+ /return typeof \w+.*.event\.triggered[\s\S]*\.event\.(dispatch|handle).*arguments/;
+
+const REACT_EVENT_NAMES = [
+ "onAbort",
+ "onAnimationEnd",
+ "onAnimationIteration",
+ "onAnimationStart",
+ "onAuxClick",
+ "onBeforeInput",
+ "onBlur",
+ "onCanPlay",
+ "onCanPlayThrough",
+ "onCancel",
+ "onChange",
+ "onClick",
+ "onClose",
+ "onCompositionEnd",
+ "onCompositionStart",
+ "onCompositionUpdate",
+ "onContextMenu",
+ "onCopy",
+ "onCut",
+ "onDoubleClick",
+ "onDrag",
+ "onDragEnd",
+ "onDragEnter",
+ "onDragExit",
+ "onDragLeave",
+ "onDragOver",
+ "onDragStart",
+ "onDrop",
+ "onDurationChange",
+ "onEmptied",
+ "onEncrypted",
+ "onEnded",
+ "onError",
+ "onFocus",
+ "onGotPointerCapture",
+ "onInput",
+ "onInvalid",
+ "onKeyDown",
+ "onKeyPress",
+ "onKeyUp",
+ "onLoad",
+ "onLoadStart",
+ "onLoadedData",
+ "onLoadedMetadata",
+ "onLostPointerCapture",
+ "onMouseDown",
+ "onMouseEnter",
+ "onMouseLeave",
+ "onMouseMove",
+ "onMouseOut",
+ "onMouseOver",
+ "onMouseUp",
+ "onPaste",
+ "onPause",
+ "onPlay",
+ "onPlaying",
+ "onPointerCancel",
+ "onPointerDown",
+ "onPointerEnter",
+ "onPointerLeave",
+ "onPointerMove",
+ "onPointerOut",
+ "onPointerOver",
+ "onPointerUp",
+ "onProgress",
+ "onRateChange",
+ "onReset",
+ "onScroll",
+ "onSeeked",
+ "onSeeking",
+ "onSelect",
+ "onStalled",
+ "onSubmit",
+ "onSuspend",
+ "onTimeUpdate",
+ "onToggle",
+ "onTouchCancel",
+ "onTouchEnd",
+ "onTouchMove",
+ "onTouchStart",
+ "onTransitionEnd",
+ "onVolumeChange",
+ "onWaiting",
+ "onWheel",
+ "onAbortCapture",
+ "onAnimationEndCapture",
+ "onAnimationIterationCapture",
+ "onAnimationStartCapture",
+ "onAuxClickCapture",
+ "onBeforeInputCapture",
+ "onBlurCapture",
+ "onCanPlayCapture",
+ "onCanPlayThroughCapture",
+ "onCancelCapture",
+ "onChangeCapture",
+ "onClickCapture",
+ "onCloseCapture",
+ "onCompositionEndCapture",
+ "onCompositionStartCapture",
+ "onCompositionUpdateCapture",
+ "onContextMenuCapture",
+ "onCopyCapture",
+ "onCutCapture",
+ "onDoubleClickCapture",
+ "onDragCapture",
+ "onDragEndCapture",
+ "onDragEnterCapture",
+ "onDragExitCapture",
+ "onDragLeaveCapture",
+ "onDragOverCapture",
+ "onDragStartCapture",
+ "onDropCapture",
+ "onDurationChangeCapture",
+ "onEmptiedCapture",
+ "onEncryptedCapture",
+ "onEndedCapture",
+ "onErrorCapture",
+ "onFocusCapture",
+ "onGotPointerCaptureCapture",
+ "onInputCapture",
+ "onInvalidCapture",
+ "onKeyDownCapture",
+ "onKeyPressCapture",
+ "onKeyUpCapture",
+ "onLoadCapture",
+ "onLoadStartCapture",
+ "onLoadedDataCapture",
+ "onLoadedMetadataCapture",
+ "onLostPointerCaptureCapture",
+ "onMouseDownCapture",
+ "onMouseEnterCapture",
+ "onMouseLeaveCapture",
+ "onMouseMoveCapture",
+ "onMouseOutCapture",
+ "onMouseOverCapture",
+ "onMouseUpCapture",
+ "onPasteCapture",
+ "onPauseCapture",
+ "onPlayCapture",
+ "onPlayingCapture",
+ "onPointerCancelCapture",
+ "onPointerDownCapture",
+ "onPointerEnterCapture",
+ "onPointerLeaveCapture",
+ "onPointerMoveCapture",
+ "onPointerOutCapture",
+ "onPointerOverCapture",
+ "onPointerUpCapture",
+ "onProgressCapture",
+ "onRateChangeCapture",
+ "onResetCapture",
+ "onScrollCapture",
+ "onSeekedCapture",
+ "onSeekingCapture",
+ "onSelectCapture",
+ "onStalledCapture",
+ "onSubmitCapture",
+ "onSuspendCapture",
+ "onTimeUpdateCapture",
+ "onToggleCapture",
+ "onTouchCancelCapture",
+ "onTouchEndCapture",
+ "onTouchMoveCapture",
+ "onTouchStartCapture",
+ "onTransitionEndCapture",
+ "onVolumeChangeCapture",
+ "onWaitingCapture",
+ "onWheelCapture",
+];
+
+/**
+ * The base class that all the enent collectors should be based upon.
+ */
+class MainEventCollector {
+ /**
+ * We allow displaying chrome events if the page is chrome or if
+ * `devtools.chrome.enabled = true`.
+ */
+ get chromeEnabled() {
+ if (typeof this._chromeEnabled === "undefined") {
+ this._chromeEnabled = Services.prefs.getBoolPref(
+ "devtools.chrome.enabled"
+ );
+ }
+
+ return this._chromeEnabled;
+ }
+
+ /**
+ * Check if a node has any event listeners attached. Please do not override
+ * this method... your getListeners() implementation needs to have the
+ * following signature:
+ * `getListeners(node, {checkOnly} = {})`
+ *
+ * @param {DOMNode} node
+ * The not for which we want to check for event listeners.
+ * @return {Boolean}
+ * true if the node has event listeners, false otherwise.
+ */
+ hasListeners(node) {
+ return this.getListeners(node, {
+ checkOnly: true,
+ });
+ }
+
+ /**
+ * Get all listeners for a node. This method must be overridden.
+ *
+ * @param {DOMNode} node
+ * The not for which we want to get event listeners.
+ * @param {Object} options
+ * An object for passing in options.
+ * @param {Boolean} [options.checkOnly = false]
+ * Don't get any listeners but return true when the first event is
+ * found.
+ * @return {Array}
+ * An array of event handlers.
+ */
+ getListeners(node, { checkOnly }) {
+ throw new Error("You have to implement the method getListeners()!");
+ }
+
+ /**
+ * Get unfiltered DOM Event listeners for a node.
+ * NOTE: These listeners may contain invalid events and events based
+ * on C++ rather than JavaScript.
+ *
+ * @param {DOMNode} node
+ * The node for which we want to get unfiltered event listeners.
+ * @return {Array}
+ * An array of unfiltered event listeners or an empty array
+ */
+ getDOMListeners(node) {
+ let listeners;
+ if (
+ typeof node.nodeName !== "undefined" &&
+ node.nodeName.toLowerCase() === "html"
+ ) {
+ const winListeners =
+ Services.els.getListenerInfoFor(node.ownerGlobal) || [];
+ const docElementListeners = Services.els.getListenerInfoFor(node) || [];
+ const docListeners =
+ Services.els.getListenerInfoFor(node.parentNode) || [];
+
+ listeners = [...winListeners, ...docElementListeners, ...docListeners];
+ } else {
+ listeners = Services.els.getListenerInfoFor(node) || [];
+ }
+
+ return listeners.filter(listener => {
+ const obj = this.unwrap(listener.listenerObject);
+ return !obj || !obj[EXCLUDED_LISTENER];
+ });
+ }
+
+ getJQuery(node) {
+ if (Cu.isDeadWrapper(node)) {
+ return null;
+ }
+
+ const global = this.unwrap(node.ownerGlobal);
+ if (!global) {
+ return null;
+ }
+
+ const hasJQuery = global.jQuery?.fn?.jquery;
+
+ if (hasJQuery) {
+ return global.jQuery;
+ }
+ return null;
+ }
+
+ unwrap(obj) {
+ return Cu.isXrayWrapper(obj) ? obj.wrappedJSObject : obj;
+ }
+
+ isChromeHandler(handler) {
+ try {
+ const handlerPrincipal = Cu.getObjectPrincipal(handler);
+
+ // Chrome codebase may register listeners on the page from a frame script or
+ // JSM <video> tags may also report internal listeners, but they won't be
+ // coming from the system principal. Instead, they will be using an expanded
+ // principal.
+ return (
+ handlerPrincipal.isSystemPrincipal ||
+ handlerPrincipal.isExpandedPrincipal
+ );
+ } catch (e) {
+ // Anything from a dead object to a CSP error can leave us here so let's
+ // return false so that we can fail gracefully.
+ return false;
+ }
+ }
+}
+
+/**
+ * Get or detect DOM events. These may include DOM events created by libraries
+ * that enable their custom events to work. At this point we are unable to
+ * effectively filter them as they may be proxied or wrapped. Although we know
+ * there is an event, we may not know the true contents until it goes
+ * through `processHandlerForEvent()`.
+ */
+class DOMEventCollector extends MainEventCollector {
+ getListeners(node, { checkOnly } = {}) {
+ const handlers = [];
+ const listeners = this.getDOMListeners(node);
+
+ for (const listener of listeners) {
+ // Ignore listeners without a type, e.g.
+ // node.addEventListener("", function() {})
+ if (!listener.type) {
+ continue;
+ }
+
+ // Get the listener object, either a Function or an Object.
+ const obj = listener.listenerObject;
+
+ // Ignore listeners without any listener, e.g.
+ // node.addEventListener("mouseover", null);
+ if (!obj) {
+ continue;
+ }
+
+ let handler = null;
+
+ // An object without a valid handleEvent is not a valid listener.
+ if (typeof obj === "object") {
+ const unwrapped = this.unwrap(obj);
+ if (typeof unwrapped.handleEvent === "function") {
+ handler = Cu.unwaiveXrays(unwrapped.handleEvent);
+ }
+ } else if (typeof obj === "function") {
+ // Ignore DOM events used to trigger jQuery events as they are only
+ // useful to the developers of the jQuery library.
+ if (JQUERY_LIVE_REGEX.test(obj.toString())) {
+ continue;
+ }
+ // Otherwise, the other valid listener type is function.
+ handler = obj;
+ }
+
+ // Ignore listeners that have no handler.
+ if (!handler) {
+ continue;
+ }
+
+ // If we shouldn't be showing chrome events due to context and this is a
+ // chrome handler we can ignore it.
+ if (!this.chromeEnabled && this.isChromeHandler(handler)) {
+ continue;
+ }
+
+ // If this is checking if a node has any listeners then we have found one
+ // so return now.
+ if (checkOnly) {
+ return true;
+ }
+
+ const eventInfo = {
+ nsIEventListenerInfo: listener,
+ capturing: listener.capturing,
+ type: listener.type,
+ handler,
+ enabled: listener.enabled,
+ };
+
+ handlers.push(eventInfo);
+ }
+
+ // If this is checking if a node has any listeners then none were found so
+ // return false.
+ if (checkOnly) {
+ return false;
+ }
+
+ return handlers;
+ }
+}
+
+/**
+ * Get or detect jQuery events.
+ */
+class JQueryEventCollector extends MainEventCollector {
+ // eslint-disable-next-line complexity
+ getListeners(node, { checkOnly } = {}) {
+ const jQuery = this.getJQuery(node);
+ const handlers = [];
+
+ // If jQuery is not on the page, if this is an anonymous node or a pseudo
+ // element we need to return early.
+ if (
+ !jQuery ||
+ isNativeAnonymous(node) ||
+ isMarkerPseudoElement(node) ||
+ isBeforePseudoElement(node) ||
+ isAfterPseudoElement(node)
+ ) {
+ if (checkOnly) {
+ return false;
+ }
+ return handlers;
+ }
+
+ let eventsObj = null;
+ const data = jQuery._data || jQuery.data;
+
+ if (data) {
+ // jQuery 1.2+
+ try {
+ eventsObj = data(node, "events");
+ } catch (e) {
+ // We have no access to a JS object. This is probably due to a CORS
+ // violation. Using try / catch is the only way to avoid this error.
+ }
+ } else {
+ // JQuery 1.0 & 1.1
+ let entry;
+ try {
+ entry = entry = jQuery(node)[0];
+ } catch (e) {
+ // We have no access to a JS object. This is probably due to a CORS
+ // violation. Using try / catch is the only way to avoid this error.
+ }
+
+ if (!entry || !entry.events) {
+ if (checkOnly) {
+ return false;
+ }
+ return handlers;
+ }
+
+ eventsObj = entry.events;
+ }
+
+ if (eventsObj) {
+ for (const [type, events] of Object.entries(eventsObj)) {
+ for (const [, event] of Object.entries(events)) {
+ // Skip events that are part of jQueries internals.
+ if (node.nodeType == node.DOCUMENT_NODE && event.selector) {
+ continue;
+ }
+
+ if (typeof event === "function" || typeof event === "object") {
+ // If we shouldn't be showing chrome events due to context and this
+ // is a chrome handler we can ignore it.
+ const handler = event.handler || event;
+ if (!this.chromeEnabled && this.isChromeHandler(handler)) {
+ continue;
+ }
+
+ if (checkOnly) {
+ return true;
+ }
+
+ const eventInfo = {
+ type,
+ handler,
+ tags: "jQuery",
+ hide: {
+ capturing: true,
+ },
+ };
+
+ handlers.push(eventInfo);
+ }
+ }
+ }
+ }
+
+ if (checkOnly) {
+ return false;
+ }
+ return handlers;
+ }
+}
+
+/**
+ * Get or detect jQuery live events.
+ */
+class JQueryLiveEventCollector extends MainEventCollector {
+ // eslint-disable-next-line complexity
+ getListeners(node, { checkOnly } = {}) {
+ const jQuery = this.getJQuery(node);
+ const handlers = [];
+
+ if (!jQuery) {
+ if (checkOnly) {
+ return false;
+ }
+ return handlers;
+ }
+
+ const data = jQuery._data || jQuery.data;
+
+ if (data) {
+ // Live events are added to the document and bubble up to all elements.
+ // Any element matching the specified selector will trigger the live
+ // event.
+ const win = this.unwrap(node.ownerGlobal);
+ let events = null;
+
+ try {
+ events = data(win.document, "events");
+ } catch (e) {
+ // We have no access to a JS object. This is probably due to a CORS
+ // violation. Using try / catch is the only way to avoid this error.
+ }
+
+ if (events) {
+ for (const [, eventHolder] of Object.entries(events)) {
+ for (const [idx, event] of Object.entries(eventHolder)) {
+ if (typeof idx !== "string" || isNaN(parseInt(idx, 10))) {
+ continue;
+ }
+
+ let selector = event.selector;
+
+ if (!selector && event.data) {
+ selector = event.data.selector || event.data || event.selector;
+ }
+
+ if (!selector || !node.ownerDocument) {
+ continue;
+ }
+
+ let matches;
+ try {
+ matches = node.matches && node.matches(selector);
+ } catch (e) {
+ // Invalid selector, do nothing.
+ }
+
+ if (!matches) {
+ continue;
+ }
+
+ if (typeof event === "function" || typeof event === "object") {
+ // If we shouldn't be showing chrome events due to context and this
+ // is a chrome handler we can ignore it.
+ const handler = event.handler || event;
+ if (!this.chromeEnabled && this.isChromeHandler(handler)) {
+ continue;
+ }
+
+ if (checkOnly) {
+ return true;
+ }
+ const eventInfo = {
+ type: event.origType || event.type.substr(selector.length + 1),
+ handler,
+ tags: "jQuery,Live",
+ hide: {
+ capturing: true,
+ },
+ };
+
+ if (!eventInfo.type && event.data?.live) {
+ eventInfo.type = event.data.live;
+ }
+
+ handlers.push(eventInfo);
+ }
+ }
+ }
+ }
+ }
+
+ if (checkOnly) {
+ return false;
+ }
+ return handlers;
+ }
+
+ normalizeListener(handlerDO) {
+ function isFunctionInProxy(funcDO) {
+ // If the anonymous function is inside the |proxy| function and the
+ // function only has guessed atom, the guessed atom should starts with
+ // "proxy/".
+ const displayName = funcDO.displayName;
+ if (displayName && displayName.startsWith("proxy/")) {
+ return true;
+ }
+
+ // If the anonymous function is inside the |proxy| function and the
+ // function gets name at compile time by SetFunctionName, its guessed
+ // atom doesn't contain "proxy/". In that case, check if the caller is
+ // "proxy" function, as a fallback.
+ const calleeDS = funcDO.environment.calleeScript;
+ if (!calleeDS) {
+ return false;
+ }
+ const calleeName = calleeDS.displayName;
+ return calleeName == "proxy";
+ }
+
+ function getFirstFunctionVariable(funcDO) {
+ // The handler function inside the |proxy| function should point the
+ // unwrapped function via environment variable.
+ const names = funcDO.environment.names();
+ for (const varName of names) {
+ const varDO = handlerDO.environment.getVariable(varName);
+ if (!varDO) {
+ continue;
+ }
+ if (varDO.class == "Function") {
+ return varDO;
+ }
+ }
+ return null;
+ }
+
+ if (!isFunctionInProxy(handlerDO)) {
+ return handlerDO;
+ }
+
+ const MAX_NESTED_HANDLER_COUNT = 2;
+ for (let i = 0; i < MAX_NESTED_HANDLER_COUNT; i++) {
+ const funcDO = getFirstFunctionVariable(handlerDO);
+ if (!funcDO) {
+ return handlerDO;
+ }
+
+ handlerDO = funcDO;
+ if (isFunctionInProxy(handlerDO)) {
+ continue;
+ }
+ break;
+ }
+
+ return handlerDO;
+ }
+}
+
+/**
+ * Get or detect React events.
+ */
+class ReactEventCollector extends MainEventCollector {
+ getListeners(node, { checkOnly } = {}) {
+ const handlers = [];
+ const props = this.getProps(node);
+
+ if (props) {
+ for (const [name, prop] of Object.entries(props)) {
+ if (REACT_EVENT_NAMES.includes(name)) {
+ const listener = prop?.__reactBoundMethod || prop;
+
+ if (typeof listener !== "function") {
+ continue;
+ }
+
+ if (!this.chromeEnabled && this.isChromeHandler(listener)) {
+ continue;
+ }
+
+ if (checkOnly) {
+ return true;
+ }
+
+ const handler = {
+ type: name,
+ handler: listener,
+ tags: "React",
+ override: {
+ capturing: name.endsWith("Capture"),
+ },
+ };
+
+ handlers.push(handler);
+ }
+ }
+ }
+
+ if (checkOnly) {
+ return false;
+ }
+
+ return handlers;
+ }
+
+ getProps(node) {
+ node = this.unwrap(node);
+
+ for (const key of Object.keys(node)) {
+ if (key.startsWith("__reactInternalInstance$")) {
+ const value = node[key];
+ if (value.memoizedProps) {
+ return value.memoizedProps; // React 16
+ }
+ return value?._currentElement?.props; // React 15
+ }
+ }
+ return null;
+ }
+
+ normalizeListener(handlerDO, listener) {
+ let functionText = "";
+
+ if (handlerDO.boundTargetFunction) {
+ handlerDO = handlerDO.boundTargetFunction;
+ }
+
+ const script = handlerDO.script;
+ // Script might be undefined (eg for methods bound several times, see
+ // https://bugzilla.mozilla.org/show_bug.cgi?id=1589658)
+ const introScript = script?.source.introductionScript;
+
+ // If this is a Babel transpiled function we have no access to the
+ // source location so we need to hide the filename and debugger
+ // icon.
+ if (introScript && introScript.displayName.endsWith("/transform.run")) {
+ listener.hide.debugger = true;
+ listener.hide.filename = true;
+
+ if (!handlerDO.isArrowFunction) {
+ functionText += "function (";
+ } else {
+ functionText += "(";
+ }
+
+ functionText += handlerDO.parameterNames.join(", ");
+
+ functionText += ") {\n";
+
+ const scriptSource = script.source.text;
+ functionText += scriptSource.substr(
+ script.sourceStart,
+ script.sourceLength
+ );
+
+ listener.override.handler = functionText;
+ }
+
+ return handlerDO;
+ }
+}
+
+/**
+ * The exposed class responsible for gathering events.
+ */
+class EventCollector {
+ constructor(targetActor) {
+ this.targetActor = targetActor;
+
+ // The event collector array. Please preserve the order otherwise there will
+ // be multiple failing tests.
+ this.eventCollectors = [
+ new ReactEventCollector(),
+ new JQueryLiveEventCollector(),
+ new JQueryEventCollector(),
+ new DOMEventCollector(),
+ ];
+ }
+
+ /**
+ * Destructor (must be called manually).
+ */
+ destroy() {
+ this.eventCollectors = null;
+ }
+
+ /**
+ * Iterate through all event collectors returning on the first found event.
+ *
+ * @param {DOMNode} node
+ * The node to be checked for events.
+ * @return {Boolean}
+ * True if the node has event listeners, false otherwise.
+ */
+ hasEventListeners(node) {
+ for (const collector of this.eventCollectors) {
+ if (collector.hasListeners(node)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * We allow displaying chrome events if the page is chrome or if
+ * `devtools.chrome.enabled = true`.
+ */
+ get chromeEnabled() {
+ if (typeof this._chromeEnabled === "undefined") {
+ this._chromeEnabled = Services.prefs.getBoolPref(
+ "devtools.chrome.enabled"
+ );
+ }
+
+ return this._chromeEnabled;
+ }
+
+ /**
+ *
+ * @param {DOMNode} node
+ * The node for which events are to be gathered.
+ * @return {Array<Object>}
+ * An array containing objects in the following format:
+ * {
+ * {String} type: The event type, e.g. "click"
+ * {Function} handler: The function called when event is triggered.
+ * {Boolean} enabled: Whether the listener is enabled or not (event listeners can
+ * be disabled via the inspector)
+ * {String} tags: Comma separated list of tags displayed inside event bubble (e.g. "JQuery")
+ * {Object} hide: Flags for hiding certain properties.
+ * {Boolean} capturing
+ * }
+ * {Boolean} native
+ * {String|undefined} sourceActor: The sourceActor id of the event listener
+ * {nsIEventListenerInfo|undefined} nsIEventListenerInfo
+ * }
+ */
+ getEventListeners(node) {
+ const listenerArray = [];
+ let dbg;
+ if (!this.chromeEnabled) {
+ dbg = new Debugger();
+ } else {
+ // When the chrome pref is turned on, we may try to debug system compartments.
+ // But since bug 1517210, the server is also loaded using the system principal
+ // and so here, we have to ensure using a special Debugger instance, loaded
+ // in a compartment flagged with invisibleToDebugger=true. This helps the Debugger
+ // know about the precise boundary between debuggee and debugger code.
+ const ChromeDebugger = require("ChromeDebugger");
+ dbg = new ChromeDebugger();
+ }
+
+ for (const collector of this.eventCollectors) {
+ const listeners = collector.getListeners(node);
+
+ if (!listeners) {
+ continue;
+ }
+
+ for (const listener of listeners) {
+ const eventObj = this.processHandlerForEvent(
+ listener,
+ dbg,
+ collector.normalizeListener
+ );
+ if (eventObj) {
+ listenerArray.push(eventObj);
+ }
+ }
+ }
+
+ listenerArray.sort((a, b) => {
+ return a.type.localeCompare(b.type);
+ });
+
+ return listenerArray;
+ }
+
+ /**
+ * Process an event listener.
+ *
+ * @param {EventListener} listener
+ * The event listener to process.
+ * @param {Debugger} dbg
+ * Debugger instance.
+ * @param {Function|null} normalizeListener
+ * An optional function that will be called to retrieve data about the listener.
+ * It should be a *Collector method.
+ *
+ * @return {Array}
+ * An array of objects where a typical object looks like this:
+ * {
+ * type: "click",
+ * handler: function() { doSomething() },
+ * origin: "http://www.mozilla.com",
+ * tags: tags,
+ * capturing: true,
+ * hide: {
+ * capturing: true
+ * },
+ * native: false,
+ * enabled: true
+ * sourceActor: "sourceActor.1234",
+ * nsIEventListenerInfo: nsIEventListenerInfo {…},
+ * }
+ */
+ // eslint-disable-next-line complexity
+ processHandlerForEvent(listener, dbg, normalizeListener) {
+ let globalDO;
+ let eventObj;
+
+ try {
+ const { capturing, handler } = listener;
+
+ const global = Cu.getGlobalForObject(handler);
+
+ // It is important that we recreate the globalDO for each handler because
+ // their global object can vary e.g. resource:// URLs on a video control. If
+ // we don't do this then all chrome listeners simply display "native code."
+ globalDO = dbg.addDebuggee(global);
+ let listenerDO = globalDO.makeDebuggeeValue(handler);
+
+ if (normalizeListener) {
+ listenerDO = normalizeListener(listenerDO, listener);
+ }
+
+ const hide = listener.hide || {};
+ const override = listener.override || {};
+ const tags = listener.tags || "";
+ const type = listener.type || "";
+ const enabled = !!listener.enabled;
+ let functionSource = handler.toString();
+ let line = 0;
+ let column = null;
+ let native = false;
+ let url = "";
+ let sourceActor = "";
+
+ // If the listener is an object with a 'handleEvent' method, use that.
+ if (
+ listenerDO.class === "Object" ||
+ /^XUL\w*Element$/.test(listenerDO.class)
+ ) {
+ let desc;
+
+ while (!desc && listenerDO) {
+ desc = listenerDO.getOwnPropertyDescriptor("handleEvent");
+ listenerDO = listenerDO.proto;
+ }
+
+ if (desc?.value) {
+ listenerDO = desc.value;
+ }
+ }
+
+ // If the listener is bound to a different context then we need to switch
+ // to the bound function.
+ if (listenerDO.isBoundFunction) {
+ listenerDO = listenerDO.boundTargetFunction;
+ }
+
+ const { isArrowFunction, name, script, parameterNames } = listenerDO;
+
+ if (script) {
+ const scriptSource = script.source.text;
+
+ line = script.startLine;
+ column = script.startColumn;
+ url = script.url;
+ const actor = this.targetActor.sourcesManager.getOrCreateSourceActor(
+ script.source
+ );
+ sourceActor = actor ? actor.actorID : null;
+
+ // Checking for the string "[native code]" is the only way at this point
+ // to check for native code. Even if this provides a false positive then
+ // grabbing the source code a second time is harmless.
+ if (
+ functionSource === "[object Object]" ||
+ functionSource === "[object XULElement]" ||
+ functionSource.includes("[native code]")
+ ) {
+ functionSource = scriptSource.substr(
+ script.sourceStart,
+ script.sourceLength
+ );
+
+ // At this point the script looks like this:
+ // () { ... }
+ // We prefix this with "function" if it is not a fat arrow function.
+ if (!isArrowFunction) {
+ functionSource = "function " + functionSource;
+ }
+ }
+ } else {
+ // If the listener is a native one (provided by C++ code) then we have no
+ // access to the script. We use the native flag to prevent showing the
+ // debugger button because the script is not available.
+ native = true;
+ }
+
+ // Arrow function text always contains the parameters. Function
+ // parameters are often missing e.g. if Array.sort is used as a handler.
+ // If they are missing we provide the parameters ourselves.
+ if (parameterNames && parameterNames.length) {
+ const prefix = "function " + name + "()";
+ const paramString = parameterNames.join(", ");
+
+ if (functionSource.startsWith(prefix)) {
+ functionSource = functionSource.substr(prefix.length);
+
+ functionSource = `function ${name} (${paramString})${functionSource}`;
+ }
+ }
+
+ // If the listener is native code we display the filename "[native code]."
+ // This is the official string and should *not* be translated.
+ let origin;
+ if (native) {
+ origin = "[native code]";
+ } else {
+ origin =
+ url +
+ (line ? ":" + line + (column === null ? "" : ":" + column) : "");
+ }
+
+ eventObj = {
+ type: override.type || type,
+ handler: override.handler || functionSource.trim(),
+ origin: override.origin || origin,
+ tags: override.tags || tags,
+ capturing:
+ typeof override.capturing !== "undefined"
+ ? override.capturing
+ : capturing,
+ hide: typeof override.hide !== "undefined" ? override.hide : hide,
+ native,
+ sourceActor,
+ nsIEventListenerInfo: listener.nsIEventListenerInfo,
+ enabled,
+ };
+
+ // Hide the debugger icon for DOM0 and native listeners. DOM0 listeners are
+ // generated dynamically from e.g. an onclick="" attribute so the script
+ // doesn't actually exist.
+ if (!sourceActor) {
+ eventObj.hide.debugger = true;
+ }
+ } finally {
+ // Ensure that we always remove the debuggee.
+ if (globalDO) {
+ dbg.removeDebuggee(globalDO);
+ }
+ }
+
+ return eventObj;
+ }
+}
+
+exports.EventCollector = EventCollector;
diff --git a/devtools/server/actors/inspector/inspector.js b/devtools/server/actors/inspector/inspector.js
new file mode 100644
index 0000000000..cdfa892889
--- /dev/null
+++ b/devtools/server/actors/inspector/inspector.js
@@ -0,0 +1,355 @@
+/* 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 { Actor } = require("resource://devtools/shared/protocol.js");
+const {
+ inspectorSpec,
+} = require("resource://devtools/shared/specs/inspector.js");
+
+const { setTimeout } = ChromeUtils.importESModule(
+ "resource://gre/modules/Timer.sys.mjs"
+);
+const {
+ LongStringActor,
+} = require("resource://devtools/server/actors/string.js");
+
+loader.lazyRequireGetter(
+ this,
+ "InspectorActorUtils",
+ "resource://devtools/server/actors/inspector/utils.js"
+);
+loader.lazyRequireGetter(
+ this,
+ "WalkerActor",
+ "resource://devtools/server/actors/inspector/walker.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "EyeDropper",
+ "resource://devtools/server/actors/highlighters/eye-dropper.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "PageStyleActor",
+ "resource://devtools/server/actors/page-style.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ ["CustomHighlighterActor", "isTypeRegistered", "HighlighterEnvironment"],
+ "resource://devtools/server/actors/highlighters.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "CompatibilityActor",
+ "resource://devtools/server/actors/compatibility/compatibility.js",
+ true
+);
+
+const SVG_NS = "http://www.w3.org/2000/svg";
+const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+
+/**
+ * Server side of the inspector actor, which is used to create
+ * inspector-related actors, including the walker.
+ */
+class InspectorActor extends Actor {
+ constructor(conn, targetActor) {
+ super(conn, inspectorSpec);
+ this.targetActor = targetActor;
+
+ this._onColorPicked = this._onColorPicked.bind(this);
+ this._onColorPickCanceled = this._onColorPickCanceled.bind(this);
+ this.destroyEyeDropper = this.destroyEyeDropper.bind(this);
+ }
+
+ destroy() {
+ super.destroy();
+ this.destroyEyeDropper();
+
+ this._compatibility = null;
+ this._pageStylePromise = null;
+ this._walkerPromise = null;
+ this.walker = null;
+ this.targetActor = null;
+ }
+
+ get window() {
+ return this.targetActor.window;
+ }
+
+ getWalker(options = {}) {
+ if (this._walkerPromise) {
+ return this._walkerPromise;
+ }
+
+ this._walkerPromise = new Promise(resolve => {
+ const domReady = () => {
+ const targetActor = this.targetActor;
+ this.walker = new WalkerActor(this.conn, targetActor, options);
+ this.manage(this.walker);
+ this.walker.once("destroyed", () => {
+ this._walkerPromise = null;
+ this._pageStylePromise = null;
+ });
+ resolve(this.walker);
+ };
+
+ if (this.window.document.readyState === "loading") {
+ // Expose an abort controller for DOMContentLoaded to remove the
+ // listener unconditionally, even if the race hits the timeout.
+ const abortController = new AbortController();
+ Promise.race([
+ new Promise(r => {
+ this.window.addEventListener("DOMContentLoaded", r, {
+ capture: true,
+ once: true,
+ signal: abortController.signal,
+ });
+ }),
+ // The DOMContentLoaded event will never be emitted on documents stuck
+ // in the loading state, for instance if document.write was called
+ // without calling document.close.
+ // TODO: It is not clear why we are waiting for the event overall, see
+ // Bug 1766279 to actually stop listening to the event altogether.
+ new Promise(r => setTimeout(r, 500)),
+ ])
+ .then(domReady)
+ .finally(() => abortController.abort());
+ } else {
+ domReady();
+ }
+ });
+
+ return this._walkerPromise;
+ }
+
+ getPageStyle() {
+ if (this._pageStylePromise) {
+ return this._pageStylePromise;
+ }
+
+ this._pageStylePromise = this.getWalker().then(walker => {
+ const pageStyle = new PageStyleActor(this);
+ this.manage(pageStyle);
+ return pageStyle;
+ });
+ return this._pageStylePromise;
+ }
+
+ getCompatibility() {
+ if (this._compatibility) {
+ return this._compatibility;
+ }
+
+ this._compatibility = new CompatibilityActor(this);
+ this.manage(this._compatibility);
+ return this._compatibility;
+ }
+
+ /**
+ * 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
+ */
+ async getHighlighterByType(typeName) {
+ if (isTypeRegistered(typeName)) {
+ const highlighterActor = new CustomHighlighterActor(this, typeName);
+ if (highlighterActor.instance.isReady) {
+ await highlighterActor.instance.isReady;
+ }
+
+ return highlighterActor;
+ }
+ 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(url, maxDim) {
+ const img = new this.window.Image();
+ img.src = url;
+
+ // imageToImageData waits for the image to load.
+ return InspectorActorUtils.imageToImageData(img, maxDim).then(imageData => {
+ return {
+ data: new 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(url, node) {
+ const document = InspectorActorUtils.isNodeDead(node)
+ ? this.window.document
+ : InspectorActorUtils.nodeDocument(node.rawNode);
+
+ if (!document) {
+ return url;
+ }
+
+ const baseURI = Services.io.newURI(document.location.href);
+ 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() {
+ this.destroyEyeDropper();
+ this._highlighterEnv = new HighlighterEnvironment();
+ this._highlighterEnv.initFromTargetActor(this.targetActor);
+ this._eyeDropper = new EyeDropper(this._highlighterEnv);
+ return this._eyeDropper.isReady;
+ }
+
+ /**
+ * Destroy the current eye-dropper highlighter instance.
+ */
+ destroyEyeDropper() {
+ 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
+ */
+ async pickColorFromPage(options) {
+ await this.createEyeDropper();
+ this._eyeDropper.show(this.window.document.documentElement, options);
+ this._eyeDropper.once("selected", this._onColorPicked);
+ this._eyeDropper.once("canceled", this._onColorPickCanceled);
+ this.targetActor.once("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() {
+ if (this._eyeDropper) {
+ this._eyeDropper.hide();
+ this._eyeDropper.off("selected", this._onColorPicked);
+ this._eyeDropper.off("canceled", this._onColorPickCanceled);
+ this.targetActor.off("will-navigate", this.destroyEyeDropper);
+ }
+ }
+
+ /**
+ * Check if the current document supports highlighters using a canvasFrame anonymous
+ * content container.
+ * It is impossible to detect the feature programmatically as some document types simply
+ * don't render the canvasFrame without throwing any error.
+ */
+ supportsHighlighters() {
+ const doc = this.targetActor.window.document;
+ const ns = doc.documentElement.namespaceURI;
+
+ // XUL documents do not support insertAnonymousContent().
+ if (ns === XUL_NS) {
+ return false;
+ }
+
+ // SVG documents do not render the canvasFrame (see Bug 1157592).
+ if (ns === SVG_NS) {
+ return false;
+ }
+
+ return true;
+ }
+
+ _onColorPicked(color) {
+ this.emit("color-picked", color);
+ }
+
+ _onColorPickCanceled() {
+ this.emit("color-pick-canceled");
+ }
+}
+
+exports.InspectorActor = InspectorActor;
diff --git a/devtools/server/actors/inspector/moz.build b/devtools/server/actors/inspector/moz.build
new file mode 100644
index 0000000000..03c69dc9fe
--- /dev/null
+++ b/devtools/server/actors/inspector/moz.build
@@ -0,0 +1,21 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DevToolsModules(
+ "constants.js",
+ "css-logic.js",
+ "custom-element-watcher.js",
+ "document-walker.js",
+ "event-collector.js",
+ "inspector.js",
+ "node-picker.js",
+ "node.js",
+ "utils.js",
+ "walker.js",
+)
+
+with Files("**"):
+ BUG_COMPONENT = ("DevTools", "Inspector")
diff --git a/devtools/server/actors/inspector/node-picker.js b/devtools/server/actors/inspector/node-picker.js
new file mode 100644
index 0000000000..bb83946b02
--- /dev/null
+++ b/devtools/server/actors/inspector/node-picker.js
@@ -0,0 +1,435 @@
+/* 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";
+
+loader.lazyRequireGetter(
+ this,
+ "isRemoteBrowserElement",
+ "resource://devtools/shared/layout/utils.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "HighlighterEnvironment",
+ "resource://devtools/server/actors/highlighters.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "RemoteNodePickerNotice",
+ "resource://devtools/server/actors/highlighters/remote-node-picker-notice.js",
+ true
+);
+
+const IS_OSX = Services.appinfo.OS === "Darwin";
+
+class NodePicker {
+ #eventListenersAbortController;
+ #remoteNodePickerNoticeHighlighter;
+
+ constructor(walker, targetActor) {
+ this._walker = walker;
+ this._targetActor = targetActor;
+
+ this._isPicking = false;
+ this._hoveredNode = null;
+ this._currentNode = null;
+
+ this._onHovered = this._onHovered.bind(this);
+ this._onKey = this._onKey.bind(this);
+ this._onPick = this._onPick.bind(this);
+ this._onSuppressedEvent = this._onSuppressedEvent.bind(this);
+ this._preventContentEvent = this._preventContentEvent.bind(this);
+ }
+
+ get remoteNodePickerNoticeHighlighter() {
+ if (!this.#remoteNodePickerNoticeHighlighter) {
+ const env = new HighlighterEnvironment();
+ env.initFromTargetActor(this._targetActor);
+ this.#remoteNodePickerNoticeHighlighter = new RemoteNodePickerNotice(env);
+ }
+
+ return this.#remoteNodePickerNoticeHighlighter;
+ }
+
+ _findAndAttachElement(event) {
+ // originalTarget allows access to the "real" element before any retargeting
+ // is applied, such as in the case of XBL anonymous elements. See also
+ // https://developer.mozilla.org/docs/XBL/XBL_1.0_Reference/Anonymous_Content#Event_Flow_and_Targeting
+ let node = event.originalTarget || event.target;
+
+ // When holding the Shift key, search for the element at the mouse position (as opposed
+ // to the event target). This would make it possible to pick nodes for which we won't
+ // get events for (e.g. elements with `pointer-events: none`).
+ if (event.shiftKey) {
+ node = this._findNodeAtMouseEventPosition(event) || node;
+ }
+
+ return this._walker.attachElement(node);
+ }
+
+ /**
+ * Return the topmost visible element located at the event mouse position. This is
+ * different from retrieving the event target as it allows to retrieve elements for which
+ * we wouldn't have mouse event triggered (e.g. elements with `pointer-events: none`)
+ *
+ * @param {MouseEvent} event
+ * @returns HTMLElement
+ */
+ _findNodeAtMouseEventPosition(event) {
+ const winUtils = this._targetActor.window.windowUtils;
+ const rectSize = 1;
+ const elements = winUtils.nodesFromRect(
+ // aX
+ event.clientX,
+ // aY
+ event.clientY,
+ // aTopSize
+ rectSize,
+ // aRightSize
+ rectSize,
+ // aBottomSize
+ rectSize,
+ // aLeftSize
+ rectSize,
+ // aIgnoreRootScrollFrame
+ true,
+ // aFlushLayout
+ false,
+ // aOnlyVisible
+ true,
+ // aTransparencyThreshold
+ 1
+ );
+
+ // ⚠️ When a highlighter was added to the page (which is the case at this point),
+ // the first element is the html node, and might be the last one as well (See Bug 1744941).
+ // Until we figure this out, let's pick the second returned item when hit this.
+ if (
+ elements.length > 1 &&
+ ChromeUtils.getClassName(elements[0]) == "HTMLHtmlElement"
+ ) {
+ return elements[1];
+ }
+
+ return elements[0];
+ }
+
+ /**
+ * Returns `true` if the event was dispatched from a window included in
+ * the current highlighter environment; or if the highlighter environment has
+ * chrome privileges
+ *
+ * @param {Event} event
+ * The event to allow
+ * @return {Boolean}
+ */
+ _isEventAllowed({ view }) {
+ // Allow "non multiprocess" browser toolbox to inspect documents loaded in the parent
+ // process (e.g. about:robots)
+ if (this._targetActor.window instanceof Ci.nsIDOMChromeWindow) {
+ return true;
+ }
+
+ return this._targetActor.windows.includes(view);
+ }
+
+ /**
+ * Returns true if the passed event original target is in the RemoteNodePickerNotice.
+ *
+ * @param {Event} event
+ * @returns {Boolean}
+ */
+ _isEventInRemoteNodePickerNotice(event) {
+ return (
+ this.#remoteNodePickerNoticeHighlighter &&
+ event.originalTarget?.closest?.(
+ `#${this.#remoteNodePickerNoticeHighlighter.rootElementId}`
+ )
+ );
+ }
+
+ /**
+ * Pick a node on click.
+ *
+ * This method doesn't respond anything interesting, however, it starts
+ * mousemove, and click listeners on the content document to fire
+ * events and let connected clients know when nodes are hovered over or
+ * clicked.
+ *
+ * Once a node is picked, events will cease, and listeners will be removed.
+ */
+ _onPick(event) {
+ // If the picked node is a remote frame, then we need to let the event through
+ // since there's a highlighter actor in that sub-frame also picking.
+ if (isRemoteBrowserElement(event.target)) {
+ return;
+ }
+
+ this._preventContentEvent(event);
+ if (!this._isEventAllowed(event)) {
+ return;
+ }
+
+ // If the click was done inside the node picker notice highlighter (e.g. clicking the
+ // close button), directly call its `onClick` method, as it doesn't have event listeners
+ // itself, to avoid managing events (+ suppressedEventListeners) for the same target
+ // from different places.
+ if (this._isEventInRemoteNodePickerNotice(event)) {
+ this.#remoteNodePickerNoticeHighlighter.onClick(event);
+ return;
+ }
+
+ // If Ctrl (Or Cmd on OSX) is pressed, this is only a preview click.
+ // Send the event to the client, but don't stop picking.
+ if ((IS_OSX && event.metaKey) || (!IS_OSX && event.ctrlKey)) {
+ this._walker.emit(
+ "picker-node-previewed",
+ this._findAndAttachElement(event)
+ );
+ return;
+ }
+
+ this._stopPicking();
+
+ if (!this._currentNode) {
+ this._currentNode = this._findAndAttachElement(event);
+ }
+
+ this._walker.emit("picker-node-picked", this._currentNode);
+ }
+
+ _onHovered(event) {
+ // If the hovered node is a remote frame, then we need to let the event through
+ // since there's a highlighter actor in that sub-frame also picking.
+ if (isRemoteBrowserElement(event.target)) {
+ return;
+ }
+
+ this._preventContentEvent(event);
+ if (!this._isEventAllowed(event)) {
+ return;
+ }
+
+ // Always call remoteNodePickerNotice handleHoveredElement so the hover state can be updated
+ // (it doesn't have its own event listeners to avoid managing events and suppressed
+ // events for the same target from different places).
+ if (this.#remoteNodePickerNoticeHighlighter) {
+ this.#remoteNodePickerNoticeHighlighter.handleHoveredElement(event);
+ if (this._isEventInRemoteNodePickerNotice(event)) {
+ return;
+ }
+ }
+
+ this._currentNode = this._findAndAttachElement(event);
+ if (this._hoveredNode !== this._currentNode.node) {
+ this._walker.emit("picker-node-hovered", this._currentNode);
+ this._hoveredNode = this._currentNode.node;
+ }
+ }
+
+ _onKey(event) {
+ if (!this._currentNode || !this._isPicking) {
+ return;
+ }
+
+ this._preventContentEvent(event);
+ if (!this._isEventAllowed(event)) {
+ return;
+ }
+
+ let currentNode = this._currentNode.node.rawNode;
+
+ /**
+ * KEY: Action/scope
+ * LEFT_KEY: wider or parent
+ * RIGHT_KEY: narrower or child
+ * ENTER/CARRIAGE_RETURN: Picks currentNode
+ * ESC/CTRL+SHIFT+C: Cancels picker, picks currentNode
+ */
+ switch (event.keyCode) {
+ // Wider.
+ case event.DOM_VK_LEFT:
+ if (!currentNode.parentElement) {
+ return;
+ }
+ currentNode = currentNode.parentElement;
+ break;
+
+ // Narrower.
+ case event.DOM_VK_RIGHT:
+ if (!currentNode.children.length) {
+ return;
+ }
+
+ // Set firstElementChild by default
+ let child = currentNode.firstElementChild;
+ // If currentNode is parent of hoveredNode, then
+ // previously selected childNode is set
+ const hoveredNode = this._hoveredNode.rawNode;
+ for (const sibling of currentNode.children) {
+ if (sibling.contains(hoveredNode) || sibling === hoveredNode) {
+ child = sibling;
+ }
+ }
+
+ currentNode = child;
+ break;
+
+ // Select the element.
+ case event.DOM_VK_RETURN:
+ this._onPick(event);
+ return;
+
+ // Cancel pick mode.
+ case event.DOM_VK_ESCAPE:
+ this.cancelPick();
+ this._walker.emit("picker-node-canceled");
+ return;
+ case event.DOM_VK_C:
+ const { altKey, ctrlKey, metaKey, shiftKey } = event;
+
+ if (
+ (IS_OSX && metaKey && altKey | shiftKey) ||
+ (!IS_OSX && ctrlKey && shiftKey)
+ ) {
+ this.cancelPick();
+ this._walker.emit("picker-node-canceled");
+ }
+ return;
+ default:
+ return;
+ }
+
+ // Store currently attached element
+ this._currentNode = this._walker.attachElement(currentNode);
+ this._walker.emit("picker-node-hovered", this._currentNode);
+ }
+
+ _onSuppressedEvent(event) {
+ if (event.type == "mousemove") {
+ this._onHovered(event);
+ } else if (event.type == "mouseup") {
+ // Suppressed mousedown/mouseup events will be sent to us before they have
+ // been converted into click events. Just treat any mouseup as a click.
+ this._onPick(event);
+ }
+ }
+
+ // In most cases, we need to prevent content events from reaching the content. This is
+ // needed to avoid triggering actions such as submitting forms or following links.
+ // In the case where the event happens on a remote frame however, we do want to let it
+ // through. That is because otherwise the pickers started in nested remote frames will
+ // never have a chance of picking their own elements.
+ _preventContentEvent(event) {
+ if (isRemoteBrowserElement(event.target)) {
+ return;
+ }
+ event.stopPropagation();
+ event.preventDefault();
+ }
+
+ /**
+ * When the debugger pauses execution in a page, events will not be delivered
+ * to any handlers added to elements on that page. This method uses the
+ * document's setSuppressedEventListener interface to bypass this restriction:
+ * events will be delivered to the callback at times when they would
+ * otherwise be suppressed. The set of events delivered this way is currently
+ * limited to mouse events.
+ *
+ * @param callback The function to call with suppressed events, or null.
+ */
+ _setSuppressedEventListener(callback) {
+ if (!this._targetActor?.window?.document) {
+ return;
+ }
+
+ // Pass the callback to setSuppressedEventListener as an EventListener.
+ this._targetActor.window.document.setSuppressedEventListener(
+ callback ? { handleEvent: callback } : null
+ );
+ }
+
+ _startPickerListeners() {
+ const target = this._targetActor.chromeEventHandler;
+ this.#eventListenersAbortController = new AbortController();
+ const config = {
+ capture: true,
+ signal: this.#eventListenersAbortController.signal,
+ };
+ target.addEventListener("mousemove", this._onHovered, config);
+ target.addEventListener("click", this._onPick, config);
+ target.addEventListener("mousedown", this._preventContentEvent, config);
+ target.addEventListener("mouseup", this._preventContentEvent, config);
+ target.addEventListener("dblclick", this._preventContentEvent, config);
+ target.addEventListener("keydown", this._onKey, config);
+ target.addEventListener("keyup", this._preventContentEvent, config);
+
+ this._setSuppressedEventListener(this._onSuppressedEvent);
+ }
+
+ _stopPickerListeners() {
+ this._setSuppressedEventListener(null);
+
+ if (this.#eventListenersAbortController) {
+ this.#eventListenersAbortController.abort();
+ this.#eventListenersAbortController = null;
+ }
+ }
+
+ _stopPicking() {
+ this._stopPickerListeners();
+ this._isPicking = false;
+ this._hoveredNode = null;
+ if (this.#remoteNodePickerNoticeHighlighter) {
+ this.#remoteNodePickerNoticeHighlighter.hide();
+ }
+ }
+
+ cancelPick() {
+ if (this._targetActor.threadActor) {
+ this._targetActor.threadActor.showOverlay();
+ }
+
+ if (this._isPicking) {
+ this._stopPicking();
+ }
+ }
+
+ pick(doFocus = false, isLocalTab = true) {
+ if (this._targetActor.threadActor) {
+ this._targetActor.threadActor.hideOverlay();
+ }
+
+ if (this._isPicking) {
+ return;
+ }
+
+ this._startPickerListeners();
+ this._isPicking = true;
+
+ if (doFocus) {
+ this._targetActor.window.focus();
+ }
+
+ if (!isLocalTab) {
+ this.remoteNodePickerNoticeHighlighter.show();
+ }
+ }
+
+ resetHoveredNodeReference() {
+ this._hoveredNode = null;
+ }
+
+ destroy() {
+ this.cancelPick();
+
+ this._targetActor = null;
+ this._walker = null;
+ this.#remoteNodePickerNoticeHighlighter = null;
+ }
+}
+
+exports.NodePicker = NodePicker;
diff --git a/devtools/server/actors/inspector/node.js b/devtools/server/actors/inspector/node.js
new file mode 100644
index 0000000000..a43dd22447
--- /dev/null
+++ b/devtools/server/actors/inspector/node.js
@@ -0,0 +1,838 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { Actor } = require("resource://devtools/shared/protocol.js");
+const {
+ nodeSpec,
+ nodeListSpec,
+} = require("resource://devtools/shared/specs/node.js");
+
+const {
+ PSEUDO_CLASSES,
+} = require("resource://devtools/shared/css/constants.js");
+
+loader.lazyRequireGetter(
+ this,
+ ["getCssPath", "getXPath", "findCssSelector"],
+ "resource://devtools/shared/inspector/css-logic.js",
+ true
+);
+
+loader.lazyRequireGetter(
+ this,
+ [
+ "getShadowRootMode",
+ "isAfterPseudoElement",
+ "isAnonymous",
+ "isBeforePseudoElement",
+ "isDirectShadowHostChild",
+ "isFrameBlockedByCSP",
+ "isFrameWithChildTarget",
+ "isMarkerPseudoElement",
+ "isNativeAnonymous",
+ "isShadowHost",
+ "isShadowRoot",
+ ],
+ "resource://devtools/shared/layout/utils.js",
+ true
+);
+
+loader.lazyRequireGetter(
+ this,
+ [
+ "getBackgroundColor",
+ "getClosestBackgroundColor",
+ "getNodeDisplayName",
+ "imageToImageData",
+ "isNodeDead",
+ ],
+ "resource://devtools/server/actors/inspector/utils.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "LongStringActor",
+ "resource://devtools/server/actors/string.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "getFontPreviewData",
+ "resource://devtools/server/actors/utils/style-utils.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "CssLogic",
+ "resource://devtools/server/actors/inspector/css-logic.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "EventCollector",
+ "resource://devtools/server/actors/inspector/event-collector.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "DOMHelpers",
+ "resource://devtools/shared/dom-helpers.js",
+ true
+);
+
+const FONT_FAMILY_PREVIEW_TEXT = "The quick brown fox jumps over the lazy dog";
+const FONT_FAMILY_PREVIEW_TEXT_SIZE = 20;
+
+/**
+ * Server side of the node actor.
+ */
+class NodeActor extends Actor {
+ constructor(walker, node) {
+ super(walker.conn, nodeSpec);
+ this.walker = walker;
+ this.rawNode = node;
+ this._eventCollector = new EventCollector(this.walker.targetActor);
+ // Map<id -> nsIEventListenerInfo> that we maintain to be able to disable/re-enable event listeners
+ // The id is generated from getEventListenerInfo
+ this._nsIEventListenersInfo = new Map();
+
+ // Store the original display type and scrollable state and whether or not the node is
+ // displayed to track changes when reflows occur.
+ const wasScrollable = this.isScrollable;
+
+ this.currentDisplayType = this.displayType;
+ this.wasDisplayed = this.isDisplayed;
+ this.wasScrollable = wasScrollable;
+
+ if (wasScrollable) {
+ this.walker.updateOverflowCausingElements(
+ this,
+ this.walker.overflowCausingElementsMap
+ );
+ }
+ }
+
+ toString() {
+ return (
+ "[NodeActor " + this.actorID + " for " + this.rawNode.toString() + "]"
+ );
+ }
+
+ isDocumentElement() {
+ return (
+ this.rawNode.ownerDocument &&
+ this.rawNode.ownerDocument.documentElement === this.rawNode
+ );
+ }
+
+ destroy() {
+ super.destroy();
+
+ if (this.mutationObserver) {
+ if (!Cu.isDeadWrapper(this.mutationObserver)) {
+ this.mutationObserver.disconnect();
+ }
+ this.mutationObserver = null;
+ }
+
+ if (this.slotchangeListener) {
+ if (!isNodeDead(this)) {
+ this.rawNode.removeEventListener("slotchange", this.slotchangeListener);
+ }
+ this.slotchangeListener = null;
+ }
+
+ if (this._waitForFrameLoadAbortController) {
+ this._waitForFrameLoadAbortController.abort();
+ this._waitForFrameLoadAbortController = null;
+ }
+ if (this._waitForFrameLoadIntervalId) {
+ clearInterval(this._waitForFrameLoadIntervalId);
+ this._waitForFrameLoadIntervalId = null;
+ }
+
+ if (this._nsIEventListenersInfo) {
+ // Re-enable all event listeners that we might have disabled
+ for (const nsIEventListenerInfo of this._nsIEventListenersInfo.values()) {
+ // If event listeners/node don't exist anymore, accessing nsIEventListenerInfo.enabled
+ // will throw.
+ try {
+ if (!nsIEventListenerInfo.enabled) {
+ nsIEventListenerInfo.enabled = true;
+ }
+ } catch (e) {
+ // ignore
+ }
+ }
+ this._nsIEventListenersInfo = null;
+ }
+
+ this._eventCollector.destroy();
+ this._eventCollector = null;
+ this.rawNode = null;
+ this.walker = null;
+ }
+
+ // Returns the JSON representation of this object over the wire.
+ form() {
+ const parentNode = this.walker.parentNode(this);
+ const inlineTextChild = this.walker.inlineTextChild(this);
+ const shadowRoot = isShadowRoot(this.rawNode);
+ const hostActor = shadowRoot
+ ? this.walker.getNode(this.rawNode.host)
+ : null;
+
+ const form = {
+ actor: this.actorID,
+ host: hostActor ? hostActor.actorID : undefined,
+ 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,
+ displayType: this.displayType,
+ isScrollable: this.isScrollable,
+ isTopLevelDocument: this.isTopLevelDocument,
+ causesOverflow: this.walker.overflowCausingElementsMap.has(this.rawNode),
+
+ // doctype attributes
+ name: this.rawNode.name,
+ publicId: this.rawNode.publicId,
+ systemId: this.rawNode.systemId,
+
+ attrs: this.writeAttrs(),
+ customElementLocation: this.getCustomElementLocation(),
+ isMarkerPseudoElement: isMarkerPseudoElement(this.rawNode),
+ isBeforePseudoElement: isBeforePseudoElement(this.rawNode),
+ isAfterPseudoElement: isAfterPseudoElement(this.rawNode),
+ isAnonymous: isAnonymous(this.rawNode),
+ isNativeAnonymous: isNativeAnonymous(this.rawNode),
+ isShadowRoot: shadowRoot,
+ shadowRootMode: getShadowRootMode(this.rawNode),
+ isShadowHost: isShadowHost(this.rawNode),
+ isDirectShadowHostChild: isDirectShadowHostChild(this.rawNode),
+ pseudoClassLocks: this.writePseudoClassLocks(),
+ mutationBreakpoints: this.walker.getMutationBreakpoints(this),
+
+ isDisplayed: this.isDisplayed,
+ isInHTMLDocument:
+ this.rawNode.ownerDocument &&
+ this.rawNode.ownerDocument.contentType === "text/html",
+ hasEventListeners: this._hasEventListeners,
+ traits: {},
+ };
+
+ if (this.isDocumentElement()) {
+ form.isDocumentElement = true;
+ }
+
+ if (isFrameBlockedByCSP(this.rawNode)) {
+ form.numChildren = 0;
+ }
+
+ // Flag the node if a different walker is needed to retrieve its children (i.e. if
+ // this is a remote frame, or if it's an iframe and we're creating targets for every iframes)
+ if (this.useChildTargetToFetchChildren) {
+ form.useChildTargetToFetchChildren = true;
+ // Declare at least one child (the #document element) so
+ // that they can be expanded.
+ form.numChildren = 1;
+ }
+ form.browsingContextID = this.rawNode.browsingContext?.id;
+
+ return form;
+ }
+
+ /**
+ * Watch the given document node for mutations using the DOM observer
+ * API.
+ */
+ watchDocument(doc, callback) {
+ if (!doc.defaultView) {
+ return;
+ }
+
+ const 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.
+ const observer = new doc.defaultView.MutationObserver(callback);
+ observer.mergeAttributeRecords = true;
+ observer.observe(node, {
+ attributes: true,
+ characterData: true,
+ characterDataOldValue: true,
+ childList: true,
+ subtree: true,
+ chromeOnlyNodes: true,
+ });
+ this.mutationObserver = observer;
+ }
+
+ /**
+ * Watch for all "slotchange" events on the node.
+ */
+ watchSlotchange(callback) {
+ this.slotchangeListener = callback;
+ this.rawNode.addEventListener("slotchange", this.slotchangeListener);
+ }
+
+ /**
+ * Check if the current node represents an element (e.g. an iframe) which has a dedicated
+ * target for its underlying document that we would need to use to fetch the child nodes.
+ * This will be the case for iframes if EFT is enabled, or if this is a remote iframe and
+ * fission is enabled.
+ */
+ get useChildTargetToFetchChildren() {
+ return isFrameWithChildTarget(this.walker.targetActor, this.rawNode);
+ }
+
+ get isTopLevelDocument() {
+ return this.rawNode === this.walker.rootDoc;
+ }
+
+ // 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 (
+ isMarkerPseudoElement(this.rawNode) ||
+ isBeforePseudoElement(this.rawNode) ||
+ isAfterPseudoElement(this.rawNode)
+ ) {
+ return 0;
+ }
+
+ const rawNode = this.rawNode;
+ let numChildren = rawNode.childNodes.length;
+ const hasContentDocument = rawNode.contentDocument;
+ const 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.
+ //
+ // FIXME: We should be able to just check <slot> rather than
+ // containingShadowRoot.
+ if (
+ numChildren === 0 ||
+ isShadowHost(this.rawNode) ||
+ this.rawNode.containingShadowRoot
+ ) {
+ numChildren = this.walker.countChildren(this);
+ }
+
+ return numChildren;
+ }
+
+ get computedStyle() {
+ if (!this._computedStyle) {
+ this._computedStyle = CssLogic.getComputedStyle(this.rawNode);
+ }
+ return this._computedStyle;
+ }
+
+ /**
+ * Returns the computed display style property value of the node.
+ */
+ get displayType() {
+ // Consider all non-element nodes as displayed.
+ if (isNodeDead(this) || this.rawNode.nodeType !== Node.ELEMENT_NODE) {
+ return null;
+ }
+
+ const style = this.computedStyle;
+ if (!style) {
+ return null;
+ }
+
+ let display = null;
+ try {
+ display = style.display;
+ } catch (e) {
+ // Fails for <scrollbar> elements.
+ }
+
+ if (
+ (display === "grid" || display === "inline-grid") &&
+ (style.gridTemplateRows.startsWith("subgrid") ||
+ style.gridTemplateColumns.startsWith("subgrid"))
+ ) {
+ display = "subgrid";
+ }
+
+ return display;
+ }
+
+ /**
+ * Check whether the node currently has scrollbars and is scrollable.
+ */
+ get isScrollable() {
+ return (
+ this.rawNode.nodeType === Node.ELEMENT_NODE &&
+ this.rawNode.hasVisibleScrollbars
+ );
+ }
+
+ /**
+ * Is the node currently displayed?
+ */
+ get isDisplayed() {
+ const type = this.displayType;
+
+ // Consider all non-elements or elements with no display-types to be displayed.
+ if (!type) {
+ return true;
+ }
+
+ // Otherwise consider elements to be displayed only if their display-types is other
+ // than "none"".
+ return type !== "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() {
+ // We need to pass a debugger instance from this compartment because
+ // otherwise we can't make use of it inside the event-collector module.
+ const dbg = this.getParent().targetActor.makeDebugger();
+ return this._eventCollector.hasEventListeners(this.rawNode, dbg);
+ }
+
+ writeAttrs() {
+ // If the node has no attributes or this.rawNode is the document node and a
+ // node with `name="attributes"` exists in the DOM we need to bail.
+ if (
+ !this.rawNode.attributes ||
+ !NamedNodeMap.isInstance(this.rawNode.attributes)
+ ) {
+ return undefined;
+ }
+
+ return [...this.rawNode.attributes].map(attr => {
+ return { namespace: attr.namespace, name: attr.name, value: attr.value };
+ });
+ }
+
+ writePseudoClassLocks() {
+ if (this.rawNode.nodeType !== Node.ELEMENT_NODE) {
+ return undefined;
+ }
+ let ret = undefined;
+ for (const pseudo of PSEUDO_CLASSES) {
+ if (InspectorUtils.hasPseudoClassLock(this.rawNode, pseudo)) {
+ ret = ret || [];
+ ret.push(pseudo);
+ }
+ }
+ return ret;
+ }
+
+ /**
+ * Retrieve the script location of the custom element definition for this node, when
+ * relevant. To be linked to a custom element definition
+ */
+ getCustomElementLocation() {
+ // Get a reference to the custom element definition function.
+ const name = this.rawNode.localName;
+
+ if (!this.rawNode.ownerGlobal) {
+ return undefined;
+ }
+
+ const customElementsRegistry = this.rawNode.ownerGlobal.customElements;
+ const customElement =
+ customElementsRegistry && customElementsRegistry.get(name);
+ if (!customElement) {
+ return undefined;
+ }
+ // Create debugger object for the customElement function.
+ const global = Cu.getGlobalForObject(customElement);
+
+ const dbg = this.getParent().targetActor.makeDebugger();
+
+ // If we hit a <browser> element of Firefox, its global will be the chrome window
+ // which is system principal and will be in the same compartment as the debuggee.
+ // For some reason, this happens when we run the content toolbox. As for the content
+ // toolboxes, the modules are loaded in the same compartment as the <browser> element,
+ // this throws as the debugger can _not_ be in the same compartment as the debugger.
+ // This happens when we toggle fission for content toolbox because we try to reparent
+ // the Walker of the tab. This happens because we do not detect in Walker.reparentRemoteFrame
+ // that the target of the tab is the top level. That's because the target is a WindowGlobalTargetActor
+ // which is retrieved via Node.getEmbedderElement and doesn't return the LocalTabTargetActor.
+ // We should probably work on TabDescriptor so that the LocalTabTargetActor has a descriptor,
+ // and see if we can possibly move the local tab specific out of the TargetActor and have
+ // the TabDescriptor expose a pure WindowGlobalTargetActor?? (See bug 1579042)
+ if (Cu.getObjectPrincipal(global) == Cu.getObjectPrincipal(dbg)) {
+ return undefined;
+ }
+
+ const globalDO = dbg.addDebuggee(global);
+ const customElementDO = globalDO.makeDebuggeeValue(customElement);
+
+ // Return undefined if we can't find a script for the custom element definition.
+ if (!customElementDO.script) {
+ return undefined;
+ }
+
+ return {
+ url: customElementDO.script.url,
+ line: customElementDO.script.startLine,
+ column: customElementDO.script.startColumn,
+ };
+ }
+
+ /**
+ * Returns a LongStringActor with the node's value.
+ */
+ getNodeValue() {
+ return new LongStringActor(this.conn, this.rawNode.nodeValue || "");
+ }
+
+ /**
+ * Set the node's value to a given string.
+ */
+ setNodeValue(value) {
+ this.rawNode.nodeValue = value;
+ }
+
+ /**
+ * Get a unique selector string for this node.
+ */
+ getUniqueSelector() {
+ if (Cu.isDeadWrapper(this.rawNode)) {
+ return "";
+ }
+ return findCssSelector(this.rawNode);
+ }
+
+ /**
+ * Get the full CSS path for this node.
+ *
+ * @return {String} A CSS selector with a part for the node and each of its ancestors.
+ */
+ getCssPath() {
+ if (Cu.isDeadWrapper(this.rawNode)) {
+ return "";
+ }
+ return getCssPath(this.rawNode);
+ }
+
+ /**
+ * Get the XPath for this node.
+ *
+ * @return {String} The XPath for finding this node on the page.
+ */
+ getXPath() {
+ if (Cu.isDeadWrapper(this.rawNode)) {
+ return "";
+ }
+ return getXPath(this.rawNode);
+ }
+
+ /**
+ * Scroll the selected node into view.
+ */
+ scrollIntoView() {
+ 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(maxDim) {
+ return imageToImageData(this.rawNode, maxDim).then(imageData => {
+ return {
+ data: new LongStringActor(this.conn, imageData.data),
+ size: imageData.size,
+ };
+ });
+ }
+
+ /**
+ * Get all event listeners that are listening on this node.
+ */
+ getEventListenerInfo() {
+ this._nsIEventListenersInfo.clear();
+
+ const eventListenersData = this._eventCollector.getEventListeners(
+ this.rawNode
+ );
+ let counter = 0;
+ for (const eventListenerData of eventListenersData) {
+ if (eventListenerData.nsIEventListenerInfo) {
+ const id = `event-listener-info-${++counter}`;
+ this._nsIEventListenersInfo.set(
+ id,
+ eventListenerData.nsIEventListenerInfo
+ );
+
+ eventListenerData.eventListenerInfoId = id;
+ // remove the nsIEventListenerInfo since we don't want to send it to the client.
+ delete eventListenerData.nsIEventListenerInfo;
+ }
+ }
+ return eventListenersData;
+ }
+
+ /**
+ * Disable a specific event listener given its associated id
+ *
+ * @param {String} eventListenerInfoId
+ */
+ disableEventListener(eventListenerInfoId) {
+ const nsEventListenerInfo =
+ this._nsIEventListenersInfo.get(eventListenerInfoId);
+ if (!nsEventListenerInfo) {
+ throw new Error("Unkown nsEventListenerInfo");
+ }
+ nsEventListenerInfo.enabled = false;
+ }
+
+ /**
+ * (Re-)enable a specific event listener given its associated id
+ *
+ * @param {String} eventListenerInfoId
+ */
+ enableEventListener(eventListenerInfoId) {
+ const nsEventListenerInfo =
+ this._nsIEventListenersInfo.get(eventListenerInfoId);
+ if (!nsEventListenerInfo) {
+ throw new Error("Unkown nsEventListenerInfo");
+ }
+ nsEventListenerInfo.enabled = true;
+ }
+
+ /**
+ * 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(modifications) {
+ const rawNode = this.rawNode;
+ for (const 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.setAttributeDevtoolsNS(
+ change.attributeNamespace,
+ change.attributeName,
+ change.newValue
+ );
+ } else {
+ rawNode.setAttributeDevtools(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(font, fillStyle = "black") {
+ const doc = this.rawNode.ownerDocument;
+ const options = {
+ previewText: FONT_FAMILY_PREVIEW_TEXT,
+ previewFontSize: FONT_FAMILY_PREVIEW_TEXT_SIZE,
+ fillStyle,
+ };
+ const { dataURL, size } = getFontPreviewData(font, doc, options);
+
+ return { data: new LongStringActor(this.conn, dataURL), size };
+ }
+
+ /**
+ * Finds the computed background color of the closest parent with a set background
+ * color.
+ *
+ * @return {String}
+ * String with the background color of the form rgba(r, g, b, a). Defaults to
+ * rgba(255, 255, 255, 1) if no background color is found.
+ */
+ getClosestBackgroundColor() {
+ return getClosestBackgroundColor(this.rawNode);
+ }
+
+ /**
+ * Finds the background color range for the parent of a single text node
+ * (i.e. for multi-colored backgrounds with gradients, images) or a single
+ * background color for single-colored backgrounds. Defaults to the closest
+ * background color if an error is encountered.
+ *
+ * @return {Object}
+ * Object with one or more of the following properties: value, min, max
+ */
+ getBackgroundColor() {
+ return getBackgroundColor(this);
+ }
+
+ /**
+ * Returns an object with the width and height of the node's owner window.
+ *
+ * @return {Object}
+ */
+ getOwnerGlobalDimensions() {
+ const win = this.rawNode.ownerGlobal;
+ return {
+ innerWidth: win.innerWidth,
+ innerHeight: win.innerHeight,
+ };
+ }
+
+ /**
+ * If the current node is an iframe, wait for the content window to be loaded.
+ */
+ async waitForFrameLoad() {
+ if (this.useChildTargetToFetchChildren) {
+ // If the document is handled by a dedicated target, we'll wait for a DOCUMENT_EVENT
+ // on the created target.
+ throw new Error(
+ "iframe content document has its own target, use that one instead"
+ );
+ }
+
+ if (Cu.isDeadWrapper(this.rawNode)) {
+ throw new Error("Node is dead");
+ }
+
+ const { contentDocument } = this.rawNode;
+ if (!contentDocument) {
+ throw new Error("Can't access contentDocument");
+ }
+
+ if (contentDocument.readyState === "uninitialized") {
+ // If the readyState is "uninitialized", the document is probably an about:blank
+ // transient document. In such case, we want to wait until the "final" document
+ // is inserted.
+
+ const { chromeEventHandler } = this.rawNode.ownerGlobal.docShell;
+ const browsingContextID = this.rawNode.browsingContext.id;
+ await new Promise((resolve, reject) => {
+ this._waitForFrameLoadAbortController = new AbortController();
+
+ chromeEventHandler.addEventListener(
+ "DOMDocElementInserted",
+ e => {
+ const { browsingContext } = e.target.defaultView;
+ // Check that the document we're notified about is the iframe one.
+ if (browsingContext.id == browsingContextID) {
+ resolve();
+ this._waitForFrameLoadAbortController.abort();
+ }
+ },
+ { signal: this._waitForFrameLoadAbortController.signal }
+ );
+
+ // It might happen that the "final" document will be a remote one, living in a
+ // different process, which means we won't get the DOMDocElementInserted event
+ // here, and will wait forever. To prevent this Promise to hang forever, we use
+ // a setInterval to check if the final document can be reached, so we can reject
+ // if it's not.
+ // This is definitely not a perfect solution, but I wasn't able to find something
+ // better for this feature. I think it's _fine_ as this method will be removed
+ // when EFT is enabled everywhere in release.
+ this._waitForFrameLoadIntervalId = setInterval(() => {
+ if (Cu.isDeadWrapper(this.rawNode) || !this.rawNode.contentDocument) {
+ reject("Can't access the iframe content document");
+ clearInterval(this._waitForFrameLoadIntervalId);
+ this._waitForFrameLoadIntervalId = null;
+ this._waitForFrameLoadAbortController.abort();
+ }
+ }, 50);
+ });
+ }
+
+ if (this.rawNode.contentDocument.readyState === "loading") {
+ await new Promise(resolve => {
+ DOMHelpers.onceDOMReady(this.rawNode.contentWindow, resolve);
+ });
+ }
+ }
+}
+
+/**
+ * Server side of a node list as returned by querySelectorAll()
+ */
+class NodeListActor extends Actor {
+ constructor(walker, nodeList) {
+ super(walker.conn, nodeListSpec);
+ this.walker = walker;
+ this.nodeList = nodeList || [];
+ }
+
+ /**
+ * Items returned by this actor should belong to the parent walker.
+ */
+ marshallPool() {
+ return this.walker;
+ }
+
+ // Returns the JSON representation of this object over the wire.
+ form() {
+ return {
+ actor: this.actorID,
+ length: this.nodeList ? this.nodeList.length : 0,
+ };
+ }
+
+ /**
+ * Get a single node from the node list.
+ */
+ item(index) {
+ return this.walker.attachElement(this.nodeList[index]);
+ }
+
+ /**
+ * Get a range of the items from the node list.
+ */
+ items(start = 0, end = this.nodeList.length) {
+ const items = Array.prototype.slice
+ .call(this.nodeList, start, end)
+ .map(item => this.walker._getOrCreateNodeActor(item));
+ return this.walker.attachElements(items);
+ }
+
+ release() {}
+}
+
+exports.NodeActor = NodeActor;
+exports.NodeListActor = NodeListActor;
diff --git a/devtools/server/actors/inspector/utils.js b/devtools/server/actors/inspector/utils.js
new file mode 100644
index 0000000000..88c1d45605
--- /dev/null
+++ b/devtools/server/actors/inspector/utils.js
@@ -0,0 +1,570 @@
+/* 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";
+
+loader.lazyRequireGetter(
+ this,
+ "colorUtils",
+ "resource://devtools/shared/css/color.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "AsyncUtils",
+ "resource://devtools/shared/async-utils.js"
+);
+loader.lazyRequireGetter(this, "flags", "resource://devtools/shared/flags.js");
+loader.lazyRequireGetter(
+ this,
+ "DevToolsUtils",
+ "resource://devtools/shared/DevToolsUtils.js"
+);
+loader.lazyRequireGetter(
+ this,
+ "nodeFilterConstants",
+ "resource://devtools/shared/dom-node-filter-constants.js"
+);
+loader.lazyRequireGetter(
+ this,
+ ["isNativeAnonymous", "getAdjustedQuads"],
+ "resource://devtools/shared/layout/utils.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "CssLogic",
+ "resource://devtools/server/actors/inspector/css-logic.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "getBackgroundFor",
+ "resource://devtools/server/actors/accessibility/audit/contrast.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ ["loadSheetForBackgroundCalculation", "removeSheetForBackgroundCalculation"],
+ "resource://devtools/server/actors/utils/accessibility.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "getTextProperties",
+ "resource://devtools/shared/accessibility.js",
+ true
+);
+
+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;
+
+/**
+ * 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;
+};
+
+/**
+ * Returns flex and grid information about a DOM node.
+ * In particular is it a grid flex/container and/or item?
+ *
+ * @param {DOMNode} node
+ * The node for which then information is required
+ * @return {Object}
+ * An object like { grid: { isContainer, isItem }, flex: { isContainer, isItem } }
+ */
+function getNodeGridFlexType(node) {
+ return {
+ grid: getNodeGridType(node),
+ flex: getNodeFlexType(node),
+ };
+}
+
+function getNodeFlexType(node) {
+ return {
+ isContainer: node.getAsFlexContainer && !!node.getAsFlexContainer(),
+ isItem: !!node.parentFlexElement,
+ };
+}
+
+function getNodeGridType(node) {
+ return {
+ isContainer: node.hasGridFragments && node.hasGridFragments(),
+ isItem: !!findGridParentContainerForNode(node),
+ };
+}
+
+function nodeDocument(node) {
+ if (Cu.isDeadWrapper(node)) {
+ return null;
+ }
+ return (
+ node.ownerDocument || (node.nodeType == Node.DOCUMENT_NODE ? node : null)
+ );
+}
+
+function isNodeDead(node) {
+ return !node || !node.rawNode || Cu.isDeadWrapper(node.rawNode);
+}
+
+function isInXULDocument(el) {
+ const doc = nodeDocument(el);
+ return doc?.documentElement && doc.documentElement.namespaceURI === XUL_NS;
+}
+
+/**
+ * This DeepTreeWalker filter skips whitespace text nodes and anonymous
+ * content with the exception of ::marker, ::before, and ::after, plus anonymous
+ * content in XUL document (needed to show all elements in the browser toolbox).
+ */
+function standardTreeWalkerFilter(node) {
+ // ::marker, ::before, and ::after are native anonymous content, but we always
+ // want to show them
+ if (
+ node.nodeName === "_moz_generated_content_marker" ||
+ 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 anonymous roots inside a non-XUL document.
+ // We need to do this to skip things like form controls, scrollbars,
+ // video controls, etc (see bug 1187482).
+ if (isNativeAnonymous(node) && !isInXULDocument(node)) {
+ return nodeFilterConstants.FILTER_SKIP;
+ }
+
+ return nodeFilterConstants.FILTER_ACCEPT;
+}
+
+/**
+ * This DeepTreeWalker filter ignores anonymous content.
+ */
+function noAnonymousContentTreeWalkerFilter(node) {
+ // Ignore all native anonymous content inside a non-XUL document.
+ // We need to do this to skip things like form controls, scrollbars,
+ // video controls, etc (see bug 1187482).
+ if (!isInXULDocument(node) && isNativeAnonymous(node)) {
+ 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 == Node.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;
+ }
+
+ const quads = node.getBoxQuads({
+ createFramesForSuppressedWhitespace: false,
+ });
+ return quads.some(quad => {
+ const bounds = quad.getBounds();
+ return bounds.width && 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) {
+ const { HTMLImageElement } = image.ownerGlobal;
+ 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.
+ const onLoad = AsyncUtils.listenOnce(image, "load");
+
+ // Reject if loading fails.
+ const 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.
+ */
+const imageToImageData = async function (node, maxDim) {
+ const { HTMLCanvasElement, HTMLImageElement } = node.ownerGlobal;
+
+ const isImg = node instanceof HTMLImageElement;
+ const 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.
+ await ensureImageLoaded(node, IMAGE_FETCHING_TIMEOUT);
+ }
+
+ // Get the image resize ratio if a maxDim was provided
+ let resizeRatio = 1;
+ const imgWidth = node.naturalWidth || node.width;
+ const imgHeight = node.naturalHeight || node.height;
+ const 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
+ const canvas = node.ownerDocument.createElementNS(XHTML_NS, "canvas");
+ canvas.width = imgWidth * resizeRatio;
+ canvas.height = imgHeight * resizeRatio;
+ const 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,
+ },
+ };
+};
+
+/**
+ * Finds the computed background color of the closest parent with a set background color.
+ *
+ * @param {DOMNode} node
+ * Node for which we want to find closest background color.
+ * @return {String}
+ * String with the background color of the form rgba(r, g, b, a). Defaults to
+ * rgba(255, 255, 255, 1) if no background color is found.
+ */
+function getClosestBackgroundColor(node) {
+ let current = node;
+
+ while (current) {
+ const computedStyle = CssLogic.getComputedStyle(current);
+ if (computedStyle) {
+ const currentStyle = computedStyle.getPropertyValue("background-color");
+ if (InspectorUtils.isValidCSSColor(currentStyle)) {
+ const currentCssColor = new colorUtils.CssColor(currentStyle);
+ if (!currentCssColor.isTransparent()) {
+ return currentCssColor.rgba;
+ }
+ }
+ }
+
+ current = current.parentNode;
+ }
+
+ return "rgba(255, 255, 255, 1)";
+}
+
+/**
+ * Finds the background image of the closest parent where it is set.
+ *
+ * @param {DOMNode} node
+ * Node for which we want to find the background image.
+ * @return {String}
+ * String with the value of the background iamge property. Defaults to "none" if
+ * no background image is found.
+ */
+function getClosestBackgroundImage(node) {
+ let current = node;
+
+ while (current) {
+ const computedStyle = CssLogic.getComputedStyle(current);
+ if (computedStyle) {
+ const currentBackgroundImage =
+ computedStyle.getPropertyValue("background-image");
+ if (currentBackgroundImage !== "none") {
+ return currentBackgroundImage;
+ }
+ }
+
+ current = current.parentNode;
+ }
+
+ return "none";
+}
+
+/**
+ * If the provided node is a grid item, then return its parent grid.
+ *
+ * @param {DOMNode} node
+ * The node that is supposedly a grid item.
+ * @return {DOMNode|null}
+ * The parent grid if found, null otherwise.
+ */
+function findGridParentContainerForNode(node) {
+ try {
+ while ((node = node.parentNode)) {
+ const display = node.ownerGlobal.getComputedStyle(node).display;
+
+ if (display.includes("grid")) {
+ return node;
+ } else if (display === "contents") {
+ // Continue walking up the tree since the parent node is a content element.
+ continue;
+ }
+
+ break;
+ }
+ } catch (e) {
+ // Getting the parentNode can fail when the supplied node is in shadow DOM.
+ }
+
+ return null;
+}
+
+/**
+ * Finds the background color range for the parent of a single text node
+ * (i.e. for multi-colored backgrounds with gradients, images) or a single
+ * background color for single-colored backgrounds. Defaults to the closest
+ * background color if an error is encountered.
+ *
+ * @param {Object}
+ * Node actor containing the following properties:
+ * {DOMNode} rawNode
+ * Node for which we want to calculate the color contrast.
+ * {WalkerActor} walker
+ * Walker actor used to check whether the node is the parent elm of a single text node.
+ * @return {Object}
+ * Object with one or more of the following properties:
+ * {Array|null} value
+ * RGBA array for single-colored background. Null for multi-colored backgrounds.
+ * {Array|null} min
+ * RGBA array for the min luminance color in a multi-colored background.
+ * Null for single-colored backgrounds.
+ * {Array|null} max
+ * RGBA array for the max luminance color in a multi-colored background.
+ * Null for single-colored backgrounds.
+ */
+async function getBackgroundColor({ rawNode: node, walker }) {
+ // Fall back to calculating contrast against closest bg if:
+ // - not element node
+ // - more than one child
+ // Avoid calculating bounds and creating doc walker by returning early.
+ if (
+ node.nodeType != Node.ELEMENT_NODE ||
+ node.childNodes.length > 1 ||
+ !node.firstChild
+ ) {
+ return {
+ value: getClosestBackgroundColorInRGBA(node),
+ };
+ }
+
+ const quads = getAdjustedQuads(node.ownerGlobal, node.firstChild, "content");
+
+ // Fall back to calculating contrast against closest bg if there are no bounds for text node.
+ // Avoid creating doc walker by returning early.
+ if (quads.length === 0 || !quads[0].bounds) {
+ return {
+ value: getClosestBackgroundColorInRGBA(node),
+ };
+ }
+
+ const bounds = quads[0].bounds;
+
+ const docWalker = walker.getDocumentWalker(node);
+ const firstChild = docWalker.firstChild();
+
+ // Fall back to calculating contrast against closest bg if:
+ // - more than one child
+ // - unique child is not a text node
+ if (
+ !firstChild ||
+ docWalker.nextSibling() ||
+ firstChild.nodeType !== Node.TEXT_NODE
+ ) {
+ return {
+ value: getClosestBackgroundColorInRGBA(node),
+ };
+ }
+
+ // Try calculating complex backgrounds for node
+ const win = node.ownerGlobal;
+ loadSheetForBackgroundCalculation(win);
+ const computedStyle = CssLogic.getComputedStyle(node);
+ const props = computedStyle ? getTextProperties(computedStyle) : null;
+
+ // Fall back to calculating contrast against closest bg if there are no text props.
+ if (!props) {
+ return {
+ value: getClosestBackgroundColorInRGBA(node),
+ };
+ }
+
+ const bgColor = await getBackgroundFor(node, {
+ bounds,
+ win,
+ convertBoundsRelativeToViewport: false,
+ size: props.size,
+ isBoldText: props.isBoldText,
+ });
+ removeSheetForBackgroundCalculation(win);
+
+ return (
+ bgColor || {
+ value: getClosestBackgroundColorInRGBA(node),
+ }
+ );
+}
+
+/**
+ *
+ * @param {DOMNode} node: The node we want the background color of
+ * @returns {Array[r,g,b,a]}
+ */
+function getClosestBackgroundColorInRGBA(node) {
+ const { r, g, b, a } = InspectorUtils.colorToRGBA(
+ getClosestBackgroundColor(node)
+ );
+ return [r, g, b, a];
+}
+/**
+ * Indicates if a document is ready (i.e. if it's not loading anymore)
+ *
+ * @param {HTMLDocument} document: The document we want to check
+ * @returns {Boolean}
+ */
+function isDocumentReady(document) {
+ if (!document) {
+ return false;
+ }
+
+ const { readyState } = document;
+ if (readyState == "interactive" || readyState == "complete") {
+ return true;
+ }
+
+ // A document might stay forever in unitialized state.
+ // If the target actor is not currently loading a document,
+ // assume the document is ready.
+ const webProgress = document.defaultView.docShell.QueryInterface(
+ Ci.nsIWebProgress
+ );
+ return !webProgress.isLoadingDocument;
+}
+
+module.exports = {
+ allAnonymousContentTreeWalkerFilter,
+ isDocumentReady,
+ isWhitespaceTextNode,
+ findGridParentContainerForNode,
+ getBackgroundColor,
+ getClosestBackgroundColor,
+ getClosestBackgroundImage,
+ getNodeDisplayName,
+ getNodeGridFlexType,
+ imageToImageData,
+ isNodeDead,
+ nodeDocument,
+ standardTreeWalkerFilter,
+ noAnonymousContentTreeWalkerFilter,
+};
diff --git a/devtools/server/actors/inspector/walker.js b/devtools/server/actors/inspector/walker.js
new file mode 100644
index 0000000000..16250d1e81
--- /dev/null
+++ b/devtools/server/actors/inspector/walker.js
@@ -0,0 +1,2753 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { Actor } = require("resource://devtools/shared/protocol.js");
+const { walkerSpec } = require("resource://devtools/shared/specs/walker.js");
+
+const {
+ LongStringActor,
+} = require("resource://devtools/server/actors/string.js");
+const {
+ EXCLUDED_LISTENER,
+} = require("resource://devtools/server/actors/inspector/constants.js");
+
+loader.lazyRequireGetter(
+ this,
+ "nodeFilterConstants",
+ "resource://devtools/shared/dom-node-filter-constants.js"
+);
+
+loader.lazyRequireGetter(
+ this,
+ [
+ "getFrameElement",
+ "isAfterPseudoElement",
+ "isBeforePseudoElement",
+ "isDirectShadowHostChild",
+ "isMarkerPseudoElement",
+ "isFrameBlockedByCSP",
+ "isFrameWithChildTarget",
+ "isShadowHost",
+ "isShadowRoot",
+ "loadSheet",
+ ],
+ "resource://devtools/shared/layout/utils.js",
+ true
+);
+
+loader.lazyRequireGetter(
+ this,
+ "throttle",
+ "resource://devtools/shared/throttle.js",
+ true
+);
+
+loader.lazyRequireGetter(
+ this,
+ [
+ "allAnonymousContentTreeWalkerFilter",
+ "findGridParentContainerForNode",
+ "isNodeDead",
+ "noAnonymousContentTreeWalkerFilter",
+ "nodeDocument",
+ "standardTreeWalkerFilter",
+ ],
+ "resource://devtools/server/actors/inspector/utils.js",
+ true
+);
+
+loader.lazyRequireGetter(
+ this,
+ "CustomElementWatcher",
+ "resource://devtools/server/actors/inspector/custom-element-watcher.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ ["DocumentWalker", "SKIP_TO_SIBLING"],
+ "resource://devtools/server/actors/inspector/document-walker.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ ["NodeActor", "NodeListActor"],
+ "resource://devtools/server/actors/inspector/node.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "NodePicker",
+ "resource://devtools/server/actors/inspector/node-picker.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "LayoutActor",
+ "resource://devtools/server/actors/layout.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ ["getLayoutChangesObserver", "releaseLayoutChangesObserver"],
+ "resource://devtools/server/actors/reflow.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "WalkerSearch",
+ "resource://devtools/server/actors/utils/walker-search.js",
+ true
+);
+
+// ContentDOMReference requires ChromeUtils, which isn't available in worker context.
+const lazy = {};
+if (!isWorker) {
+ loader.lazyGetter(
+ lazy,
+ "ContentDOMReference",
+ () =>
+ ChromeUtils.importESModule(
+ "resource://gre/modules/ContentDOMReference.sys.mjs",
+ {
+ // ContentDOMReference needs to be retrieved from the shared global
+ // since it is a shared singleton.
+ loadInDevToolsLoader: false,
+ }
+ ).ContentDOMReference
+ );
+}
+
+loader.lazyServiceGetter(
+ this,
+ "eventListenerService",
+ "@mozilla.org/eventlistenerservice;1",
+ "nsIEventListenerService"
+);
+
+// Minimum delay between two "new-mutations" events.
+const MUTATIONS_THROTTLING_DELAY = 100;
+// List of mutation types that should -not- be throttled.
+const IMMEDIATE_MUTATIONS = ["pseudoClassLock"];
+
+const HIDDEN_CLASS = "__fx-devtools-hide-shortcut__";
+
+// 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],
+ ["::marker", 0],
+];
+
+const HELPER_SHEET =
+ "data:text/css;charset=utf-8," +
+ encodeURIComponent(`
+ .__fx-devtools-hide-shortcut__ {
+ visibility: hidden !important;
+ }
+`);
+
+/**
+ * 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;
+};
+
+/**
+ * Server side of the DOM walker.
+ */
+class WalkerActor extends Actor {
+ /**
+ * Create the WalkerActor
+ * @param {DevToolsServerConnection} conn
+ * The server connection.
+ * @param {TargetActor} targetActor
+ * The top-level Actor for this tab.
+ * @param {Object} options
+ * - {Boolean} showAllAnonymousContent: Show all native anonymous content
+ */
+ constructor(conn, targetActor, options) {
+ super(conn, walkerSpec);
+ this.targetActor = targetActor;
+ this.rootWin = targetActor.window;
+ this.rootDoc = this.rootWin.document;
+
+ // Map of already created node actors, keyed by their corresponding DOMNode.
+ this._nodeActorsMap = new Map();
+
+ this._pendingMutations = [];
+ this._activePseudoClassLocks = new Set();
+ this._mutationBreakpoints = new WeakMap();
+ this._anonParents = new WeakMap();
+ this.customElementWatcher = new CustomElementWatcher(
+ targetActor.chromeEventHandler
+ );
+
+ // In this map, the key-value pairs are the overflow causing elements and their
+ // respective ancestor scrollable node actor.
+ this.overflowCausingElementsMap = new Map();
+
+ 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.onSubtreeModified = this.onSubtreeModified.bind(this);
+ this.onSubtreeModified[EXCLUDED_LISTENER] = true;
+ this.onNodeRemoved = this.onNodeRemoved.bind(this);
+ this.onNodeRemoved[EXCLUDED_LISTENER] = true;
+ this.onAttributeModified = this.onAttributeModified.bind(this);
+ this.onAttributeModified[EXCLUDED_LISTENER] = true;
+
+ this.onMutations = this.onMutations.bind(this);
+ this.onSlotchange = this.onSlotchange.bind(this);
+ this.onShadowrootattached = this.onShadowrootattached.bind(this);
+ this.onAnonymousrootcreated = this.onAnonymousrootcreated.bind(this);
+ this.onAnonymousrootremoved = this.onAnonymousrootremoved.bind(this);
+ this.onFrameLoad = this.onFrameLoad.bind(this);
+ this.onFrameUnload = this.onFrameUnload.bind(this);
+ this.onCustomElementDefined = this.onCustomElementDefined.bind(this);
+ this._throttledEmitNewMutations = throttle(
+ this._emitNewMutations.bind(this),
+ MUTATIONS_THROTTLING_DELAY
+ );
+
+ targetActor.on("will-navigate", this.onFrameUnload);
+ targetActor.on("window-ready", this.onFrameLoad);
+
+ this.customElementWatcher.on(
+ "element-defined",
+ this.onCustomElementDefined
+ );
+
+ // Keep a reference to the chromeEventHandler for the current targetActor, to make
+ // sure we will be able to remove the listener during the WalkerActor destroy().
+ this.chromeEventHandler = targetActor.chromeEventHandler;
+ // shadowrootattached is a chrome-only event. We enable it below.
+ this.chromeEventHandler.addEventListener(
+ "shadowrootattached",
+ this.onShadowrootattached
+ );
+ // anonymousrootcreated is a chrome-only event. We enable it below.
+ this.chromeEventHandler.addEventListener(
+ "anonymousrootcreated",
+ this.onAnonymousrootcreated
+ );
+ this.chromeEventHandler.addEventListener(
+ "anonymousrootremoved",
+ this.onAnonymousrootremoved
+ );
+ for (const { document } of this.targetActor.windows) {
+ document.devToolsAnonymousAndShadowEventsEnabled = true;
+ }
+
+ // Ensure that the root document node actor is ready and
+ // managed.
+ this.rootNode = this.document();
+
+ this.layoutChangeObserver = getLayoutChangesObserver(this.targetActor);
+ 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);
+ }
+
+ get nodePicker() {
+ if (!this._nodePicker) {
+ this._nodePicker = new NodePicker(this, this.targetActor);
+ }
+
+ return this._nodePicker;
+ }
+
+ watchRootNode() {
+ if (this.rootNode) {
+ this.emit("root-available", this.rootNode);
+ }
+ }
+
+ /**
+ * Callback for eventListenerService.addListenerChangeListener
+ * @param nsISimpleEnumerator changesEnum
+ * enumerator of nsIEventListenerChange
+ */
+ _onEventListenerChange(changesEnum) {
+ for (const current of changesEnum.enumerate(Ci.nsIEventListenerChange)) {
+ const target = current.target;
+
+ if (this._nodeActorsMap.has(target)) {
+ const actor = this.getNode(target);
+ const mutation = {
+ type: "events",
+ target: actor.actorID,
+ hasEventListeners: actor._hasEventListeners,
+ };
+ this.queueMutation(mutation);
+ }
+ }
+ }
+
+ // Returns the JSON representation of this object over the wire.
+ form() {
+ return {
+ actor: this.actorID,
+ root: this.rootNode.form(),
+ traits: {},
+ };
+ }
+
+ toString() {
+ return "[WalkerActor " + this.actorID + "]";
+ }
+
+ getDocumentWalker(node, skipTo) {
+ // Allow native anon content (like <video> controls) if preffed on
+ const filter = this.showAllAnonymousContent
+ ? allAnonymousContentTreeWalkerFilter
+ : standardTreeWalkerFilter;
+
+ return new DocumentWalker(node, this.rootWin, {
+ filter,
+ skipTo,
+ showAnonymousContent: true,
+ });
+ }
+
+ destroy() {
+ if (this._destroyed) {
+ return;
+ }
+ this._destroyed = true;
+ super.destroy();
+ try {
+ this.clearPseudoClassLocks();
+ this._activePseudoClassLocks = null;
+
+ this.overflowCausingElementsMap.clear();
+ this.overflowCausingElementsMap = null;
+
+ this._hoveredNode = null;
+ this.rootWin = null;
+ this.rootDoc = null;
+ this.rootNode = null;
+ this.layoutHelpers = null;
+ this._orphaned = null;
+ this._retainedOrphans = null;
+ this._nodeActorsMap = null;
+
+ this.targetActor.off("will-navigate", this.onFrameUnload);
+ this.targetActor.off("window-ready", this.onFrameLoad);
+ this.customElementWatcher.off(
+ "element-defined",
+ this.onCustomElementDefined
+ );
+
+ this.chromeEventHandler.removeEventListener(
+ "shadowrootattached",
+ this.onShadowrootattached
+ );
+ this.chromeEventHandler.removeEventListener(
+ "anonymousrootcreated",
+ this.onAnonymousrootcreated
+ );
+ this.chromeEventHandler.removeEventListener(
+ "anonymousrootremoved",
+ this.onAnonymousrootremoved
+ );
+
+ // This attribute is just for devtools, so we can unset once we're done.
+ for (const { document } of this.targetActor.windows) {
+ document.devToolsAnonymousAndShadowEventsEnabled = false;
+ }
+
+ this.onFrameLoad = null;
+ this.onFrameUnload = null;
+
+ this.customElementWatcher.destroy();
+ this.customElementWatcher = null;
+
+ this.walkerSearch.destroy();
+
+ if (this._nodePicker) {
+ this._nodePicker.destroy();
+ this._nodePicker = null;
+ }
+
+ this.layoutChangeObserver.off("reflows", this._onReflows);
+ this.layoutChangeObserver.off("resize", this._onResize);
+ this.layoutChangeObserver = null;
+ releaseLayoutChangesObserver(this.targetActor);
+
+ eventListenerService.removeListenerChangeListener(
+ this._onEventListenerChange
+ );
+
+ this.onMutations = null;
+
+ this.layoutActor = null;
+ this.targetActor = null;
+ this.chromeEventHandler = null;
+
+ this.emit("destroyed");
+ } catch (e) {
+ console.error(e);
+ }
+ }
+
+ release() {}
+
+ unmanage(actor) {
+ if (actor instanceof NodeActor) {
+ if (
+ this._activePseudoClassLocks &&
+ this._activePseudoClassLocks.has(actor)
+ ) {
+ this.clearPseudoClassLocks(actor);
+ }
+
+ this.customElementWatcher.unmanageNode(actor);
+
+ this._nodeActorsMap.delete(actor.rawNode);
+ }
+ super.unmanage(actor);
+ }
+
+ /**
+ * Determine if the walker has come across this DOM node before.
+ * @param {DOMNode} rawNode
+ * @return {Boolean}
+ */
+ hasNode(rawNode) {
+ return this._nodeActorsMap.has(rawNode);
+ }
+
+ /**
+ * If the walker has come across this DOM node before, then get the
+ * corresponding node actor.
+ * @param {DOMNode} rawNode
+ * @return {NodeActor}
+ */
+ getNode(rawNode) {
+ return this._nodeActorsMap.get(rawNode);
+ }
+
+ /**
+ * Internal helper that will either retrieve the existing NodeActor for the
+ * provided node or create the actor on the fly if it doesn't exist.
+ * This method should only be called when we are sure that the node should be
+ * known by the client and that the parent node is already known.
+ *
+ * Otherwise prefer `getNode` to only retrieve known actors or `attachElement`
+ * to create node actors recursively.
+ *
+ * @param {DOMNode} node
+ * The node for which we want to create or get an actor
+ * @return {NodeActor} The corresponding NodeActor
+ */
+ _getOrCreateNodeActor(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._nodeActorsMap.set(node, actor);
+
+ if (node.nodeType === Node.DOCUMENT_NODE) {
+ actor.watchDocument(node, this.onMutations);
+ }
+
+ if (isShadowRoot(actor.rawNode)) {
+ actor.watchDocument(node.ownerDocument, this.onMutations);
+ actor.watchSlotchange(this.onSlotchange);
+ }
+
+ this.customElementWatcher.manageNode(actor);
+
+ return actor;
+ }
+
+ /**
+ * When a custom element is defined, send a customElementDefined mutation for all the
+ * NodeActors using this tag name.
+ */
+ onCustomElementDefined({ name, actors }) {
+ actors.forEach(actor =>
+ this.queueMutation({
+ target: actor.actorID,
+ type: "customElementDefined",
+ customElementLocation: actor.getCustomElementLocation(),
+ })
+ );
+ }
+
+ _onReflows(reflows) {
+ // Going through the nodes the walker knows about, see which ones have had their
+ // display, scrollable or overflow state changed and send events if any.
+ const displayTypeChanges = [];
+ const scrollableStateChanges = [];
+
+ const currentOverflowCausingElementsMap = new Map();
+
+ for (const [node, actor] of this._nodeActorsMap) {
+ if (Cu.isDeadWrapper(node)) {
+ continue;
+ }
+
+ const displayType = actor.displayType;
+ const isDisplayed = actor.isDisplayed;
+
+ if (
+ displayType !== actor.currentDisplayType ||
+ isDisplayed !== actor.wasDisplayed
+ ) {
+ displayTypeChanges.push(actor);
+
+ // Updating the original value
+ actor.currentDisplayType = displayType;
+ actor.wasDisplayed = isDisplayed;
+ }
+
+ const isScrollable = actor.isScrollable;
+ if (isScrollable !== actor.wasScrollable) {
+ scrollableStateChanges.push(actor);
+ actor.wasScrollable = isScrollable;
+ }
+
+ if (isScrollable) {
+ this.updateOverflowCausingElements(
+ actor,
+ currentOverflowCausingElementsMap
+ );
+ }
+ }
+
+ // Get the NodeActor for each node in the symmetric difference of
+ // currentOverflowCausingElementsMap and this.overflowCausingElementsMap
+ const overflowStateChanges = [...currentOverflowCausingElementsMap.keys()]
+ .filter(node => !this.overflowCausingElementsMap.has(node))
+ .concat(
+ [...this.overflowCausingElementsMap.keys()].filter(
+ node => !currentOverflowCausingElementsMap.has(node)
+ )
+ )
+ .filter(node => this.hasNode(node))
+ .map(node => this.getNode(node));
+
+ this.overflowCausingElementsMap = currentOverflowCausingElementsMap;
+
+ if (overflowStateChanges.length) {
+ this.emit("overflow-change", overflowStateChanges);
+ }
+
+ if (displayTypeChanges.length) {
+ this.emit("display-change", displayTypeChanges);
+ }
+
+ if (scrollableStateChanges.length) {
+ this.emit("scrollable-change", scrollableStateChanges);
+ }
+ }
+
+ /**
+ * When the browser window gets resized, relay the event to the front.
+ */
+ _onResize() {
+ this.emit("resize");
+ }
+
+ /**
+ * 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(node) {
+ const { nodes, newParents } = this.attachElements([node]);
+ return {
+ node: nodes[0],
+ 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(nodes) {
+ const nodeActors = [];
+ const 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 use the closest ancestor.
+ if (!this.showAllAnonymousContent) {
+ while (
+ node &&
+ standardTreeWalkerFilter(node) != nodeFilterConstants.FILTER_ACCEPT
+ ) {
+ node = this.rawParentNode(node);
+ }
+ if (!node) {
+ continue;
+ }
+ }
+
+ node = this._getOrCreateNodeActor(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(node) {
+ const doc = isNodeDead(node) ? this.rootDoc : nodeDocument(node.rawNode);
+ return this._getOrCreateNodeActor(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(node) {
+ const elt = isNodeDead(node)
+ ? this.rootDoc.documentElement
+ : nodeDocument(node.rawNode).documentElement;
+ return this._getOrCreateNodeActor(elt);
+ }
+
+ parentNode(node) {
+ const parent = this.rawParentNode(node);
+ if (parent) {
+ return this._getOrCreateNodeActor(parent);
+ }
+
+ return null;
+ }
+
+ rawParentNode(node) {
+ const rawNode = node instanceof NodeActor ? node.rawNode : node;
+ if (rawNode == this.rootDoc) {
+ return null;
+ }
+ return InspectorUtils.getParentForNode(rawNode, /* anonymous = */ true);
+ }
+
+ /**
+ * 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({ rawNode }) {
+ // Quick checks to prevent creating a new walker if possible.
+ if (
+ isMarkerPseudoElement(rawNode) ||
+ isBeforePseudoElement(rawNode) ||
+ isAfterPseudoElement(rawNode) ||
+ isShadowHost(rawNode) ||
+ rawNode.nodeType != Node.ELEMENT_NODE ||
+ !!rawNode.children.length ||
+ isFrameWithChildTarget(this.targetActor, rawNode) ||
+ isFrameBlockedByCSP(rawNode)
+ ) {
+ return undefined;
+ }
+
+ const children = this._rawChildren(rawNode, /* includeAssigned = */ true);
+ const firstChild = children[0];
+
+ // 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
+ // - we are a slot -> these are always represented on their own lines with
+ // a link to the original node.
+ // - we are a flex item -> these are always shown on their own lines so they can be
+ // selected by the flexbox inspector.
+ const isAssignedToSlot =
+ firstChild &&
+ rawNode.nodeName === "SLOT" &&
+ isDirectShadowHostChild(firstChild);
+
+ const isFlexItem = !!firstChild?.parentFlexElement;
+
+ if (
+ !firstChild ||
+ children.length > 1 ||
+ firstChild.nodeType !== Node.TEXT_NODE ||
+ firstChild.nodeValue.length > gValueSummaryLength ||
+ isAssignedToSlot ||
+ isFlexItem
+ ) {
+ return undefined;
+ }
+
+ return this._getOrCreateNodeActor(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(node) {
+ node.retained = true;
+ }
+
+ /**
+ * Remove the 'retained' mark from a node. If the node was a
+ * retained orphan, release it.
+ */
+ unretainNode(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(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);
+ }
+
+ for (const child of this._rawChildren(node.rawNode)) {
+ const childActor = this.getNode(child);
+ if (childActor) {
+ this.releaseNode(childActor, options);
+ }
+ }
+
+ node.destroy();
+ }
+
+ /**
+ * Add any nodes between `node` and the walker's root node that have not
+ * yet been seen by the client.
+ */
+ ensurePathToRoot(node, newParents = new Set()) {
+ if (!node) {
+ return newParents;
+ }
+ let parent = this.rawParentNode(node);
+ while (parent) {
+ let parentActor = this.getNode(parent);
+ if (parentActor) {
+ // This parent did exist, so the client knows about it.
+ return newParents;
+ }
+ // This parent didn't exist, so hasn't been seen by the client yet.
+ parentActor = this._getOrCreateNodeActor(parent);
+ newParents.add(parentActor);
+ parent = this.rawParentNode(parentActor);
+ }
+ return newParents;
+ }
+
+ /**
+ * Return the number of children under the provided NodeActor.
+ *
+ * @param NodeActor node
+ * See JSDoc for children()
+ * @param object options
+ * See JSDoc for children()
+ * @return Number the number of children
+ */
+ countChildren(node, options = {}) {
+ return this._getChildren(node, options).nodes.length;
+ }
+
+ /**
+ * 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`.
+ *
+ * @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: Array of NodeActor representing the nodes returned by the request.
+ */
+ children(node, options = {}) {
+ const { hasFirst, hasLast, nodes } = this._getChildren(node, options);
+ return {
+ hasFirst,
+ hasLast,
+ nodes: nodes.map(n => this._getOrCreateNodeActor(n)),
+ };
+ }
+
+ /**
+ * Returns the raw children of the DOM node, with anon content filtered as needed
+ * @param Node rawNode.
+ * @param boolean includeAssigned
+ * Whether <slot> assigned children should be returned. See
+ * HTMLSlotElement.assignedNodes().
+ * @returns Array<Node> the list of children.
+ */
+ _rawChildren(rawNode, includeAssigned) {
+ const filter = this.showAllAnonymousContent
+ ? allAnonymousContentTreeWalkerFilter
+ : standardTreeWalkerFilter;
+ const ret = [];
+ const children = InspectorUtils.getChildrenForNode(
+ rawNode,
+ /* anonymous = */ true,
+ includeAssigned
+ );
+ for (const child of children) {
+ if (filter(child) == nodeFilterConstants.FILTER_ACCEPT) {
+ ret.push(child);
+ }
+ }
+ return ret;
+ }
+
+ /**
+ * Return chidlren of the given node. Contrary to children children(), this method only
+ * returns DOMNodes. Therefore it will not create NodeActor wrappers and will not
+ * update the nodeActors map for the discovered nodes either. This makes this method
+ * safe to call when you are not sure if the discovered nodes will be communicated to
+ * the client.
+ *
+ * @param NodeActor node
+ * See JSDoc for children()
+ * @param object options
+ * See JSDoc for children()
+ * @return 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: Array of DOMNodes.
+ */
+ // eslint-disable-next-line complexity
+ _getChildren(node, options = {}) {
+ if (isNodeDead(node) || isFrameBlockedByCSP(node.rawNode)) {
+ 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;
+ }
+
+ let nodes = this._rawChildren(node.rawNode, /* includeAssigned = */ true);
+ let hasFirst = true;
+ let hasLast = true;
+ if (nodes.length > maxNodes) {
+ let startIndex;
+ if (options.center) {
+ const centerIndex = nodes.indexOf(options.center.rawNode);
+ const backwardCount = Math.floor(maxNodes / 2);
+ // If centering would hit the end, just read the last maxNodes nodes.
+ if (centerIndex - backwardCount + maxNodes >= nodes.length) {
+ startIndex = nodes.length - maxNodes;
+ } else {
+ startIndex = Math.max(0, centerIndex - backwardCount);
+ }
+ } else if (options.start) {
+ startIndex = Math.max(0, nodes.indexOf(options.start.rawNode));
+ } else {
+ startIndex = 0;
+ }
+ const endIndex = Math.min(startIndex + maxNodes, nodes.length);
+ hasFirst = startIndex == 0;
+ hasLast = endIndex >= nodes.length;
+ nodes = nodes.slice(startIndex, endIndex);
+ }
+
+ return { hasFirst, hasLast, nodes };
+ }
+
+ /**
+ * Get the next sibling of a given node. Getting nodes one at a time
+ * might be inefficient, be careful.
+ */
+ nextSibling(node) {
+ if (isNodeDead(node)) {
+ return null;
+ }
+
+ const walker = this.getDocumentWalker(node.rawNode);
+ const sibling = walker.nextSibling();
+ return sibling ? this._getOrCreateNodeActor(sibling) : null;
+ }
+
+ /**
+ * Get the previous sibling of a given node. Getting nodes one at a time
+ * might be inefficient, be careful.
+ */
+ previousSibling(node) {
+ if (isNodeDead(node)) {
+ return null;
+ }
+
+ const walker = this.getDocumentWalker(node.rawNode);
+ const sibling = walker.previousSibling();
+ return sibling ? this._getOrCreateNodeActor(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(walker, count) {
+ const ret = [];
+
+ let node = walker.currentNode;
+ do {
+ if (!walker.isSkippedNode(node)) {
+ // The walker can be on a node that would be filtered out if it didn't find any
+ // other node to fallback to.
+ ret.push(node);
+ }
+ node = walker.nextSibling();
+ } while (node && --count);
+ return ret;
+ }
+
+ /**
+ * 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(baseNode, selector) {
+ if (isNodeDead(baseNode)) {
+ return {};
+ }
+
+ const 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(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(selector) {
+ let nodes = [];
+
+ for (const { document } of this.targetActor.windows) {
+ try {
+ nodes = [...nodes, ...document.querySelectorAll(selector)];
+ } catch (e) {
+ // Bad selector. Do nothing as the selector can come from a searchbox.
+ }
+ }
+
+ return nodes;
+ }
+
+ /**
+ * Get a list of nodes that match the given XPath in all known frames of
+ * the current content page.
+ * @param {String} xPath.
+ * @return {Array}
+ */
+ _multiFrameXPath(xPath) {
+ const nodes = [];
+
+ for (const window of this.targetActor.windows) {
+ const document = window.document;
+ try {
+ const result = document.evaluate(
+ xPath,
+ document.documentElement,
+ null,
+ window.XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
+ null
+ );
+
+ for (let i = 0; i < result.snapshotLength; i++) {
+ nodes.push(result.snapshotItem(i));
+ }
+ } catch (e) {
+ // Bad XPath. Do nothing as the XPath can come from a searchbox.
+ }
+ }
+
+ return nodes;
+ }
+
+ /**
+ * Return a NodeListActor with all nodes that match the given XPath in all
+ * frames of the current content page.
+ * @param {String} xPath
+ */
+ multiFrameXPath(xPath) {
+ return new NodeListActor(this, this._multiFrameXPath(xPath));
+ }
+
+ /**
+ * 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(query) {
+ const results = this.walkerSearch.search(query);
+ const 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"
+ */
+ // eslint-disable-next-line complexity
+ getSuggestionsForQuery(query, completing, selectorState) {
+ const 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 (const node of nodes) {
+ for (const className of node.classList) {
+ sugs.classes.set(className, (sugs.classes.get(className) | 0) + 1);
+ }
+ }
+ sugs.classes.delete("");
+ sugs.classes.delete(HIDDEN_CLASS);
+ for (const [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 (const node of nodes) {
+ sugs.ids.set(node.id, (sugs.ids.get(node.id) | 0) + 1);
+ }
+ for (const [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 (const node of nodes) {
+ const tag = node.localName;
+ sugs.tags.set(tag, (sugs.tags.get(tag) | 0) + 1);
+ }
+ for (const [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 (const node of nodes) {
+ sugs.ids.set(node.id, (sugs.ids.get(node.id) | 0) + 1);
+ const tag = node.localName;
+ sugs.tags.set(tag, (sugs.tags.get(tag) | 0) + 1);
+ for (const className of node.classList) {
+ sugs.classes.set(className, (sugs.classes.get(className) | 0) + 1);
+ }
+ }
+ for (const [tag, count] of sugs.tags) {
+ tag && result.push([tag, count]);
+ }
+ for (const [id, count] of sugs.ids) {
+ id && result.push(["#" + id, count]);
+ }
+ sugs.classes.delete("");
+ sugs.classes.delete(HIDDEN_CLASS);
+ for (const [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
+ const firstA = a[0].substring(0, 1);
+ const firstB = b[0].substring(0, 1);
+
+ const getSortKeyPrefix = firstLetter => {
+ if (firstLetter === "#") {
+ return "2";
+ }
+ if (firstLetter === ".") {
+ return "1";
+ }
+ return "0";
+ };
+
+ sortA = getSortKeyPrefix(firstA) + sortA;
+ sortB = getSortKeyPrefix(firstB) + sortB;
+
+ // String compare
+ return sortA.localeCompare(sortB);
+ });
+
+ result = result.slice(0, 25);
+
+ return {
+ query,
+ suggestions: result,
+ };
+ }
+
+ /**
+ * Add a pseudo-class lock to a node.
+ *
+ * @param NodeActor node
+ * @param string pseudo
+ * A pseudoclass: ':hover', ':active', ':focus', ':focus-within'
+ * @param options
+ * Options object:
+ * `parents`: True if the pseudo-class should be added
+ * to parent nodes.
+ * `enabled`: False if the pseudo-class should be locked
+ * to 'off'. Defaults to true.
+ *
+ * @returns An empty packet. A "pseudoClassLock" mutation will
+ * be queued for any changed nodes.
+ */
+ addPseudoClassLock(node, pseudo, options = {}) {
+ if (isNodeDead(node)) {
+ return;
+ }
+
+ // There can be only one node locked per pseudo, so dismiss all existing
+ // ones
+ for (const locked of this._activePseudoClassLocks) {
+ if (InspectorUtils.hasPseudoClassLock(locked.rawNode, pseudo)) {
+ this._removePseudoClassLock(locked, pseudo);
+ }
+ }
+
+ const enabled = options.enabled === undefined || options.enabled;
+ this._addPseudoClassLock(node, pseudo, enabled);
+
+ if (!options.parents) {
+ return;
+ }
+
+ const walker = this.getDocumentWalker(node.rawNode);
+ let cur;
+ while ((cur = walker.parentNode())) {
+ const curNode = this._getOrCreateNodeActor(cur);
+ this._addPseudoClassLock(curNode, pseudo, enabled);
+ }
+ }
+
+ _queuePseudoClassMutation(node) {
+ this.queueMutation({
+ target: node.actorID,
+ type: "pseudoClassLock",
+ pseudoClassLocks: node.writePseudoClassLocks(),
+ });
+ }
+
+ _addPseudoClassLock(node, pseudo, enabled) {
+ if (node.rawNode.nodeType !== Node.ELEMENT_NODE) {
+ return false;
+ }
+ InspectorUtils.addPseudoClassLock(node.rawNode, pseudo, enabled);
+ this._activePseudoClassLocks.add(node);
+ this._queuePseudoClassMutation(node);
+ return true;
+ }
+
+ hideNode(node) {
+ if (isNodeDead(node)) {
+ return;
+ }
+
+ loadSheet(node.rawNode.ownerGlobal, HELPER_SHEET);
+ node.rawNode.classList.add(HIDDEN_CLASS);
+ }
+
+ unhideNode(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', ':focus-within'
+ * @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(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 (const locked of this._activePseudoClassLocks) {
+ if (
+ node.rawNode.contains(locked.rawNode) &&
+ InspectorUtils.hasPseudoClassLock(locked.rawNode, pseudo)
+ ) {
+ this._removePseudoClassLock(locked, pseudo);
+ }
+ }
+
+ if (!options.parents) {
+ return;
+ }
+
+ const walker = this.getDocumentWalker(node.rawNode);
+ let cur;
+ while ((cur = walker.parentNode())) {
+ const curNode = this._getOrCreateNodeActor(cur);
+ this._removePseudoClassLock(curNode, pseudo);
+ }
+ }
+
+ _removePseudoClassLock(node, pseudo) {
+ if (node.rawNode.nodeType != Node.ELEMENT_NODE) {
+ return false;
+ }
+ InspectorUtils.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(node) {
+ if (node && isNodeDead(node)) {
+ return;
+ }
+
+ if (node) {
+ InspectorUtils.clearPseudoClassLocks(node.rawNode);
+ this._activePseudoClassLocks.delete(node);
+ this._queuePseudoClassMutation(node);
+ } else {
+ for (const locked of this._activePseudoClassLocks) {
+ InspectorUtils.clearPseudoClassLocks(locked.rawNode);
+ this._activePseudoClassLocks.delete(locked);
+ this._queuePseudoClassMutation(locked);
+ }
+ }
+ }
+
+ /**
+ * Get a node's innerHTML property.
+ */
+ innerHTML(node) {
+ let html = "";
+ if (!isNodeDead(node)) {
+ html = node.rawNode.innerHTML;
+ }
+ return new LongStringActor(this.conn, html);
+ }
+
+ /**
+ * Set a node's innerHTML property.
+ *
+ * @param {NodeActor} node The node.
+ * @param {string} value The piece of HTML content.
+ */
+ setInnerHTML(node, value) {
+ if (isNodeDead(node)) {
+ return;
+ }
+
+ const rawNode = node.rawNode;
+ if (
+ rawNode.nodeType !== rawNode.ownerDocument.ELEMENT_NODE &&
+ rawNode.nodeType !== rawNode.ownerDocument.DOCUMENT_FRAGMENT_NODE
+ ) {
+ throw new Error("Can only change innerHTML to element or fragment nodes");
+ }
+ // eslint-disable-next-line no-unsanitized/property
+ rawNode.innerHTML = value;
+ }
+
+ /**
+ * Get a node's outerHTML property.
+ *
+ * @param {NodeActor} node The node.
+ */
+ outerHTML(node) {
+ let outerHTML = "";
+ if (!isNodeDead(node)) {
+ outerHTML = node.rawNode.outerHTML;
+ }
+ return new LongStringActor(this.conn, outerHTML);
+ }
+
+ /**
+ * Set a node's outerHTML property.
+ *
+ * @param {NodeActor} node The node.
+ * @param {string} value The piece of HTML content.
+ */
+ setOuterHTML(node, value) {
+ if (isNodeDead(node)) {
+ return;
+ }
+
+ const rawNode = node.rawNode;
+ const doc = nodeDocument(rawNode);
+ const win = doc.defaultView;
+ let parser;
+ if (!win) {
+ throw new Error("The window object shouldn't be null");
+ } else {
+ // We create DOMParser under window object because we want a content
+ // DOMParser, which means all the DOM objects created by this DOMParser
+ // will be in the same DocGroup as rawNode.parentNode. Then the newly
+ // created nodes can be adopted into rawNode.parentNode.
+ parser = new win.DOMParser();
+ }
+
+ const mimeType = rawNode.tagName === "svg" ? "image/svg+xml" : "text/html";
+ const parsedDOM = parser.parseFromString(value, mimeType);
+ const 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 {
+ // eslint-disable-next-line no-unsanitized/property
+ rawNode.outerHTML = value;
+ }
+ } else if (rawNode.tagName === "HEAD") {
+ if (parsedDOM.body.innerHTML === "") {
+ parentNode.replaceChild(parsedDOM.head, rawNode);
+ } else {
+ // eslint-disable-next-line no-unsanitized/property
+ rawNode.outerHTML = value;
+ }
+ } else if (node.isDocumentElement()) {
+ // Unable to set outerHTML on the document element. Fall back by
+ // setting attributes manually. Then replace all the child nodes.
+ const finalAttributeModifications = [];
+ const attributeModifications = {};
+ for (const attribute of rawNode.attributes) {
+ attributeModifications[attribute.name] = null;
+ }
+ for (const attribute of parsedDOM.documentElement.attributes) {
+ attributeModifications[attribute.name] = attribute.value;
+ }
+ for (const key in attributeModifications) {
+ finalAttributeModifications.push({
+ attributeName: key,
+ newValue: attributeModifications[key],
+ });
+ }
+ node.modifyAttributes(finalAttributeModifications);
+
+ rawNode.replaceChildren(...parsedDOM.firstElementChild.childNodes);
+ } else {
+ // eslint-disable-next-line no-unsanitized/property
+ 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(node, position, value) {
+ if (isNodeDead(node)) {
+ return { node: [], newParents: [] };
+ }
+
+ const rawNode = node.rawNode;
+ const 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.");
+ }
+
+ const 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().
+ const range = rawNode.ownerDocument.createRange();
+ if (position === "beforeBegin" || position === "afterEnd") {
+ range.selectNode(rawNode);
+ } else {
+ range.selectNodeContents(rawNode);
+ }
+ // eslint-disable-next-line no-unsanitized/method
+ const docFrag = range.createContextualFragment(value);
+ const 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({ rawNode }) {
+ const 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(node) {
+ return (
+ (node.rawNode.ownerDocument &&
+ node.rawNode.ownerDocument.documentElement === this.rawNode) ||
+ node.rawNode.nodeType === Node.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(node) {
+ if (isNodeDead(node) || this.isDocumentOrDocumentElementNode(node)) {
+ throw Error("Cannot remove document, document elements or dead nodes.");
+ }
+
+ const 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(nodes) {
+ // Check that all nodes are valid before processing the removals.
+ for (const node of nodes) {
+ if (isNodeDead(node) || this.isDocumentOrDocumentElementNode(node)) {
+ throw Error("Cannot remove document, document elements or dead nodes");
+ }
+ }
+
+ for (const node of nodes) {
+ node.rawNode.remove();
+ // Mutation events will take care of the rest.
+ }
+ }
+
+ /**
+ * Insert a node into the DOM.
+ */
+ insertBefore(node, parent, sibling) {
+ if (
+ isNodeDead(node) ||
+ isNodeDead(parent) ||
+ (sibling && isNodeDead(sibling))
+ ) {
+ return;
+ }
+
+ const rawNode = node.rawNode;
+ const rawParent = parent.rawNode;
+ const 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(node, tagName) {
+ if (isNodeDead(node)) {
+ return null;
+ }
+
+ const 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)
+ );
+ }
+
+ const 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;
+ }
+
+ /**
+ * Gets the state of the mutation breakpoint types for this actor.
+ *
+ * @param {NodeActor} node The node to get breakpoint info for.
+ */
+ getMutationBreakpoints(node) {
+ let bps;
+ if (!isNodeDead(node)) {
+ bps = this._breakpointInfoForNode(node.rawNode);
+ }
+
+ return (
+ bps || {
+ subtree: false,
+ removal: false,
+ attribute: false,
+ }
+ );
+ }
+
+ /**
+ * Set the state of some subset of mutation breakpoint types for this actor.
+ *
+ * @param {NodeActor} node The node to set breakpoint info for.
+ * @param {Object} bps A subset of the breakpoints for this actor that
+ * should be updated to new states.
+ */
+ setMutationBreakpoints(node, bps) {
+ if (isNodeDead(node)) {
+ return;
+ }
+ const rawNode = node.rawNode;
+
+ if (
+ rawNode.ownerDocument &&
+ rawNode.getRootNode({ composed: true }) != rawNode.ownerDocument
+ ) {
+ // We only allow watching for mutations on nodes that are attached to
+ // documents. That allows us to clean up our mutation listeners when all
+ // of the watched nodes have been removed from the document.
+ return;
+ }
+
+ // This argument has nullable fields so we want to only update boolean
+ // field values.
+ const bpsForNode = Object.keys(bps).reduce((obj, bp) => {
+ if (typeof bps[bp] === "boolean") {
+ obj[bp] = bps[bp];
+ }
+ return obj;
+ }, {});
+
+ this._updateMutationBreakpointState("api", rawNode, {
+ ...this.getMutationBreakpoints(node),
+ ...bpsForNode,
+ });
+ }
+
+ /**
+ * Update the mutation breakpoint state for the given DOM node.
+ *
+ * @param {Node} rawNode The DOM node.
+ * @param {Object} bpsForNode The state of each mutation bp type we support.
+ */
+ _updateMutationBreakpointState(mutationReason, rawNode, bpsForNode) {
+ const rawDoc = rawNode.ownerDocument || rawNode;
+
+ const docMutationBreakpoints = this._mutationBreakpointsForDoc(
+ rawDoc,
+ true /* createIfNeeded */
+ );
+ let originalBpsForNode = this._breakpointInfoForNode(rawNode);
+
+ if (!bpsForNode && !originalBpsForNode) {
+ return;
+ }
+
+ bpsForNode = bpsForNode || {};
+ originalBpsForNode = originalBpsForNode || {};
+
+ if (Object.values(bpsForNode).some(Boolean)) {
+ docMutationBreakpoints.nodes.set(rawNode, bpsForNode);
+ } else {
+ docMutationBreakpoints.nodes.delete(rawNode);
+ }
+ if (originalBpsForNode.subtree && !bpsForNode.subtree) {
+ docMutationBreakpoints.counts.subtree -= 1;
+ } else if (!originalBpsForNode.subtree && bpsForNode.subtree) {
+ docMutationBreakpoints.counts.subtree += 1;
+ }
+
+ if (originalBpsForNode.removal && !bpsForNode.removal) {
+ docMutationBreakpoints.counts.removal -= 1;
+ } else if (!originalBpsForNode.removal && bpsForNode.removal) {
+ docMutationBreakpoints.counts.removal += 1;
+ }
+
+ if (originalBpsForNode.attribute && !bpsForNode.attribute) {
+ docMutationBreakpoints.counts.attribute -= 1;
+ } else if (!originalBpsForNode.attribute && bpsForNode.attribute) {
+ docMutationBreakpoints.counts.attribute += 1;
+ }
+
+ this._updateDocumentMutationListeners(rawDoc);
+
+ const actor = this.getNode(rawNode);
+ if (actor) {
+ this.queueMutation({
+ target: actor.actorID,
+ type: "mutationBreakpoint",
+ mutationBreakpoints: this.getMutationBreakpoints(actor),
+ mutationReason,
+ });
+ }
+ }
+
+ /**
+ * Controls whether this DOM document has event listeners attached for
+ * handling of DOM mutation breakpoints.
+ *
+ * @param {Document} rawDoc The DOM document.
+ */
+ _updateDocumentMutationListeners(rawDoc) {
+ const docMutationBreakpoints = this._mutationBreakpointsForDoc(rawDoc);
+ if (!docMutationBreakpoints) {
+ rawDoc.devToolsWatchingDOMMutations = false;
+ return;
+ }
+
+ const anyBreakpoint =
+ docMutationBreakpoints.counts.subtree > 0 ||
+ docMutationBreakpoints.counts.removal > 0 ||
+ docMutationBreakpoints.counts.attribute > 0;
+
+ rawDoc.devToolsWatchingDOMMutations = anyBreakpoint;
+
+ if (docMutationBreakpoints.counts.subtree > 0) {
+ this.chromeEventHandler.addEventListener(
+ "devtoolschildinserted",
+ this.onSubtreeModified,
+ true /* capture */
+ );
+ } else {
+ this.chromeEventHandler.removeEventListener(
+ "devtoolschildinserted",
+ this.onSubtreeModified,
+ true /* capture */
+ );
+ }
+
+ if (anyBreakpoint) {
+ this.chromeEventHandler.addEventListener(
+ "devtoolschildremoved",
+ this.onNodeRemoved,
+ true /* capture */
+ );
+ } else {
+ this.chromeEventHandler.removeEventListener(
+ "devtoolschildremoved",
+ this.onNodeRemoved,
+ true /* capture */
+ );
+ }
+
+ if (docMutationBreakpoints.counts.attribute > 0) {
+ this.chromeEventHandler.addEventListener(
+ "devtoolsattrmodified",
+ this.onAttributeModified,
+ true /* capture */
+ );
+ } else {
+ this.chromeEventHandler.removeEventListener(
+ "devtoolsattrmodified",
+ this.onAttributeModified,
+ true /* capture */
+ );
+ }
+ }
+
+ _breakOnMutation(mutationType, targetNode, ancestorNode, action) {
+ this.targetActor.threadActor.pauseForMutationBreakpoint(
+ mutationType,
+ targetNode,
+ ancestorNode,
+ action
+ );
+ }
+
+ _mutationBreakpointsForDoc(rawDoc, createIfNeeded = false) {
+ let docMutationBreakpoints = this._mutationBreakpoints.get(rawDoc);
+ if (!docMutationBreakpoints && createIfNeeded) {
+ docMutationBreakpoints = {
+ counts: {
+ subtree: 0,
+ removal: 0,
+ attribute: 0,
+ },
+ nodes: new Map(),
+ };
+ this._mutationBreakpoints.set(rawDoc, docMutationBreakpoints);
+ }
+ return docMutationBreakpoints;
+ }
+
+ _breakpointInfoForNode(target) {
+ const docMutationBreakpoints = this._mutationBreakpointsForDoc(
+ target.ownerDocument || target
+ );
+ return (
+ (docMutationBreakpoints && docMutationBreakpoints.nodes.get(target)) ||
+ null
+ );
+ }
+
+ onNodeRemoved(evt) {
+ const mutationBpInfo = this._breakpointInfoForNode(evt.target);
+ const hasNodeRemovalEvent = mutationBpInfo?.removal;
+
+ this._clearMutationBreakpointsFromSubtree(evt.target);
+ if (hasNodeRemovalEvent) {
+ this._breakOnMutation("nodeRemoved", evt.target);
+ } else {
+ this.onSubtreeModified(evt);
+ }
+ }
+
+ onAttributeModified(evt) {
+ const mutationBpInfo = this._breakpointInfoForNode(evt.target);
+ if (mutationBpInfo?.attribute) {
+ this._breakOnMutation("attributeModified", evt.target);
+ }
+ }
+
+ onSubtreeModified(evt) {
+ const action = evt.type === "devtoolschildinserted" ? "add" : "remove";
+ let node = evt.target;
+ if (node.isNativeAnonymous && !this.showAllAnonymousContent) {
+ return;
+ }
+ while ((node = node.parentNode) !== null) {
+ const mutationBpInfo = this._breakpointInfoForNode(node);
+ if (mutationBpInfo?.subtree) {
+ this._breakOnMutation("subtreeModified", evt.target, node, action);
+ break;
+ }
+ }
+ }
+
+ _clearMutationBreakpointsFromSubtree(targetNode) {
+ const targetDoc = targetNode.ownerDocument || targetNode;
+ const docMutationBreakpoints = this._mutationBreakpointsForDoc(targetDoc);
+ if (!docMutationBreakpoints || docMutationBreakpoints.nodes.size === 0) {
+ // Bail early for performance. If the doc has no mutation BPs, there is
+ // no reason to iterate through the children looking for things to detach.
+ return;
+ }
+
+ // The walker is not limited to the subtree of the argument node, so we
+ // need to ensure that we stop walking when we leave the subtree.
+ const nextWalkerSibling = this._getNextTraversalSibling(targetNode);
+
+ const walker = new DocumentWalker(targetNode, this.rootWin, {
+ filter: noAnonymousContentTreeWalkerFilter,
+ skipTo: SKIP_TO_SIBLING,
+ });
+
+ do {
+ this._updateMutationBreakpointState("detach", walker.currentNode, null);
+ } while (walker.nextNode() && walker.currentNode !== nextWalkerSibling);
+ }
+
+ _getNextTraversalSibling(targetNode) {
+ const walker = new DocumentWalker(targetNode, this.rootWin, {
+ filter: noAnonymousContentTreeWalkerFilter,
+ skipTo: SKIP_TO_SIBLING,
+ });
+
+ while (!walker.nextSibling()) {
+ if (!walker.parentNode()) {
+ // If we try to step past the walker root, there is no next sibling.
+ return null;
+ }
+ }
+ return walker.currentNode;
+ }
+
+ /**
+ * 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(options = {}) {
+ const pending = this._pendingMutations || [];
+ this._pendingMutations = [];
+ this._waitingForGetMutations = false;
+
+ if (options.cleanup) {
+ for (const 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(mutation) {
+ if (!this.actorID || this._destroyed) {
+ // We've been destroyed, don't bother queueing this mutation.
+ return;
+ }
+
+ // Add the mutation to the list of mutations to be retrieved next.
+ this._pendingMutations.push(mutation);
+
+ // Bail out if we already emitted a new-mutations event and are waiting for a client
+ // to retrieve them.
+ if (this._waitingForGetMutations) {
+ return;
+ }
+
+ if (IMMEDIATE_MUTATIONS.includes(mutation.type)) {
+ this._emitNewMutations();
+ } else {
+ /**
+ * If many mutations are fired at the same time, clients might sequentially request
+ * children/siblings for updated nodes, which can be costly. By throttling the calls
+ * to getMutations, duplicated mutations will be ignored.
+ */
+ this._throttledEmitNewMutations();
+ }
+ }
+
+ _emitNewMutations() {
+ if (!this.actorID || this._destroyed) {
+ // Bail out if the actor was destroyed after throttling this call.
+ return;
+ }
+
+ if (this._waitingForGetMutations || !this._pendingMutations.length) {
+ // Bail out if we already fired the new-mutation event or if no mutations are
+ // waiting to be retrieved.
+ return;
+ }
+
+ this._waitingForGetMutations = true;
+ this.emit("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(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.
+ this.emit("any-mutation");
+
+ for (const change of mutations) {
+ const targetActor = this.getNode(change.target);
+ if (!targetActor) {
+ continue;
+ }
+ const targetNode = change.target;
+ const type = change.type;
+ const mutation = {
+ 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") {
+ // Get the list of removed and added actors that the client has seen
+ // so that it can keep its ownership tree up to date.
+ const removedActors = [];
+ const addedActors = [];
+ for (const removed of change.removedNodes) {
+ const 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 (const added of change.addedNodes) {
+ const 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;
+
+ const 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(mutation) {
+ const { oldValue, target } = mutation;
+ const newValue = target.nodeValue;
+ const 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;
+ }
+
+ const parentActor = this.getNode(target.parentNode);
+ if (!parentActor || parentActor.rawNode.children.length) {
+ // If the parent node has other children, a character data mutation will
+ // not change anything regarding inlining text nodes.
+ return;
+ }
+
+ const inlineTextChild = this.inlineTextChild(parentActor);
+ this.queueMutation({
+ type: "inlineTextChild",
+ target: parentActor.actorID,
+ inlineTextChild: inlineTextChild ? inlineTextChild.form() : undefined,
+ });
+ }
+
+ onSlotchange(event) {
+ const target = event.target;
+ const targetActor = this.getNode(target);
+ if (!targetActor) {
+ return;
+ }
+
+ this.queueMutation({
+ type: "slotchange",
+ target: targetActor.actorID,
+ });
+ }
+
+ /**
+ * Fires when an anonymous root is created.
+ * This is needed because regular mutation observers don't fire on some kinds
+ * of NAC creation. We want to treat this like a regular insertion.
+ */
+ onAnonymousrootcreated(event) {
+ const root = event.target;
+ const parent = this.rawParentNode(root);
+ if (!parent) {
+ // These events are async. The node might have been removed already, in
+ // which case there's nothing to do anymore.
+ return;
+ }
+ // By the time onAnonymousrootremoved fires, the node is already detached
+ // from its parent, so we need to remember it by hand.
+ this._anonParents.set(root, parent);
+ this.onMutations([
+ {
+ type: "childList",
+ target: parent,
+ addedNodes: [root],
+ removedNodes: [],
+ },
+ ]);
+ }
+
+ /**
+ * @see onAnonymousrootcreated
+ */
+ onAnonymousrootremoved(event) {
+ const root = event.target;
+ const parent = this._anonParents.get(root);
+ if (!parent) {
+ return;
+ }
+ this._anonParents.delete(root);
+ this.onMutations([
+ {
+ type: "childList",
+ target: parent,
+ addedNodes: [],
+ removedNodes: [root],
+ },
+ ]);
+ }
+
+ onShadowrootattached(event) {
+ const actor = this.getNode(event.target);
+ if (!actor) {
+ return;
+ }
+
+ const mutation = {
+ type: "shadowRootAttached",
+ target: actor.actorID,
+ };
+ this.queueMutation(mutation);
+ }
+
+ onFrameLoad({ window, isTopLevel }) {
+ // By the time we receive the DOMContentLoaded event, we might have been destroyed
+ if (this._destroyed) {
+ return;
+ }
+ const { readyState } = window.document;
+ if (readyState != "interactive" && readyState != "complete") {
+ // The document is not loaded, so we want to register to fire again when the
+ // DOM has been loaded.
+ window.addEventListener(
+ "DOMContentLoaded",
+ this.onFrameLoad.bind(this, { window, isTopLevel }),
+ { once: true }
+ );
+ return;
+ }
+
+ window.document.shadowRootAttachedEventEnabled = true;
+
+ 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 &&
+ this.rootDoc !== window.document &&
+ !Cu.isDeadWrapper(this.rootDoc) &&
+ this.rootDoc.defaultView
+ ) {
+ this.onFrameUnload({ window: this.rootDoc.defaultView });
+ }
+ // Update all DOM objects references to target the new document.
+ this.rootWin = window;
+ this.rootDoc = window.document;
+ this.rootNode = this.document();
+ this.emit("root-available", this.rootNode);
+ } else {
+ const frame = getFrameElement(window);
+ const frameActor = this.getNode(frame);
+ if (frameActor) {
+ // If the parent frame is in the map of known node actors, create the
+ // actor for the new document and emit a root-available event.
+ const documentActor = this._getOrCreateNodeActor(window.document);
+ this.emit("root-available", documentActor);
+ }
+ }
+ }
+
+ // Returns true if domNode is in window or a subframe.
+ _childOfWindow(window, domNode) {
+ while (domNode) {
+ const win = nodeDocument(domNode).defaultView;
+ if (win === window) {
+ return true;
+ }
+ domNode = getFrameElement(win);
+ }
+ return false;
+ }
+
+ onFrameUnload({ window }) {
+ // Any retained orphans that belong to this document
+ // or its children need to be released, and a mutation sent
+ // to notify of that.
+ const releasedOrphans = [];
+
+ for (const 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) {
+ this.queueMutation({
+ target: this.rootNode.actorID,
+ type: "unretained",
+ nodes: releasedOrphans,
+ });
+ }
+
+ const doc = window.document;
+ const documentActor = this.getNode(doc);
+ if (!documentActor) {
+ return;
+ }
+
+ // Removing a frame also removes any mutation breakpoints set on that
+ // document so that clients can clear their set of active breakpoints.
+ const mutationBps = this._mutationBreakpointsForDoc(doc);
+ const nodes = mutationBps ? Array.from(mutationBps.nodes.keys()) : [];
+ for (const node of nodes) {
+ this._updateMutationBreakpointState("unload", node, null);
+ }
+
+ this.emit("root-destroyed", documentActor);
+
+ // Cleanup root doc references if we just unloaded the top level root
+ // document.
+ if (this.rootDoc === doc) {
+ this.rootDoc = null;
+ this.rootNode = null;
+ }
+
+ // Release the actor for the unloaded document.
+ this.releaseNode(documentActor, { force: true });
+ }
+
+ /**
+ * Check if a node is attached to the DOM tree of the current page.
+ * @param {Node} rawNode
+ * @return {Boolean} false if the node is removed from the tree or within a
+ * document fragment
+ */
+ _isInDOMTree(rawNode) {
+ let walker;
+ try {
+ walker = this.getDocumentWalker(rawNode);
+ } catch (e) {
+ // The DocumentWalker may throw NS_ERROR_ILLEGAL_VALUE when the node isn't found as a legit children of its parent
+ // ex: <iframe> manually added as immediate child of another <iframe>
+ if (e.name == "NS_ERROR_ILLEGAL_VALUE") {
+ return false;
+ }
+ throw e;
+ }
+ 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 === Node.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(node) {
+ if (isNodeDead(node)) {
+ return false;
+ }
+ return this._isInDOMTree(node.rawNode);
+ }
+
+ /**
+ * Given a windowID return the NodeActor for the corresponding frameElement,
+ * unless it's the root window
+ */
+ getNodeActorFromWindowID(windowID) {
+ let win;
+
+ try {
+ win = Services.wm.getOuterWindowWithId(windowID);
+ } catch (e) {
+ // ignore
+ }
+
+ if (!win) {
+ return {
+ error: "noWindow",
+ message: "The related docshell is destroyed or not found",
+ };
+ } else if (!win.frameElement) {
+ // the frame element of the root document is privileged & thus
+ // inaccessible, so return the document body/element instead
+ return this.attachElement(
+ win.document.body || win.document.documentElement
+ );
+ }
+
+ return this.attachElement(win.frameElement);
+ }
+
+ /**
+ * Given a contentDomReference return the NodeActor for the corresponding frameElement.
+ */
+ getNodeActorFromContentDomReference(contentDomReference) {
+ let rawNode = lazy.ContentDOMReference.resolve(contentDomReference);
+ if (!rawNode || !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 StyleSheet resource 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(resourceId) {
+ const manager = this.targetActor.getStyleSheetsManager();
+ const ownerNode = manager.getOwnerNode(resourceId);
+ return this.attachElement(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.
+ * const inspectorFront = await toolbox.target.getFront("inspector");
+ * // Retrieve the walker.
+ * const walker = inspectorFront.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(actorID, path) {
+ const actor = this.conn.getActor(actorID);
+ if (!actor) {
+ return null;
+ }
+
+ let obj = actor;
+ for (const 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() {
+ if (!this.layoutActor) {
+ this.layoutActor = new LayoutActor(this.conn, this.targetActor, this);
+ }
+
+ return this.layoutActor;
+ }
+
+ /**
+ * Returns the parent grid DOMNode of the given node if it exists, otherwise, it
+ * returns null.
+ */
+ getParentGridNode(node) {
+ if (isNodeDead(node)) {
+ return null;
+ }
+
+ const parentGridNode = findGridParentContainerForNode(node.rawNode);
+ return parentGridNode ? this._getOrCreateNodeActor(parentGridNode) : null;
+ }
+
+ /**
+ * Returns the offset parent DOMNode of the given node if it exists, otherwise, it
+ * returns null.
+ */
+ getOffsetParent(node) {
+ if (isNodeDead(node)) {
+ return null;
+ }
+
+ const offsetParent = node.rawNode.offsetParent;
+
+ if (!offsetParent) {
+ return null;
+ }
+
+ return this._getOrCreateNodeActor(offsetParent);
+ }
+
+ getEmbedderElement(browsingContextID) {
+ const browsingContext = BrowsingContext.get(browsingContextID);
+ let rawNode = browsingContext.embedderElement;
+ 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);
+ }
+
+ pick(doFocus, isLocalTab) {
+ this.nodePicker.pick(doFocus, isLocalTab);
+ }
+
+ cancelPick() {
+ this.nodePicker.cancelPick();
+ }
+
+ clearPicker() {
+ this.nodePicker.resetHoveredNodeReference();
+ }
+
+ /**
+ * Given a scrollable node, find its descendants which are causing overflow in it and
+ * add their raw nodes to the map as keys with the scrollable element as the values.
+ *
+ * @param {NodeActor} scrollableNode A scrollable node.
+ * @param {Map} map The map to which the overflow causing elements are added.
+ */
+ updateOverflowCausingElements(scrollableNode, map) {
+ if (
+ isNodeDead(scrollableNode) ||
+ scrollableNode.rawNode.nodeType !== Node.ELEMENT_NODE
+ ) {
+ return;
+ }
+
+ const overflowCausingChildren = [
+ ...InspectorUtils.getOverflowingChildrenOfElement(scrollableNode.rawNode),
+ ];
+
+ for (let overflowCausingChild of overflowCausingChildren) {
+ // overflowCausingChild is a Node, but not necessarily an Element.
+ // So, get the containing Element
+ if (overflowCausingChild.nodeType !== Node.ELEMENT_NODE) {
+ overflowCausingChild = overflowCausingChild.parentElement;
+ }
+ map.set(overflowCausingChild, scrollableNode);
+ }
+ }
+
+ /**
+ * Returns an array of the overflow causing elements' NodeActor for the given node.
+ *
+ * @param {NodeActor} node The scrollable node.
+ * @return {Array<NodeActor>} An array of the overflow causing elements.
+ */
+ getOverflowCausingElements(node) {
+ if (
+ isNodeDead(node) ||
+ node.rawNode.nodeType !== Node.ELEMENT_NODE ||
+ !node.isScrollable
+ ) {
+ return [];
+ }
+
+ const overflowCausingElements = [
+ ...InspectorUtils.getOverflowingChildrenOfElement(node.rawNode),
+ ].map(overflowCausingChild => {
+ if (overflowCausingChild.nodeType !== Node.ELEMENT_NODE) {
+ overflowCausingChild = overflowCausingChild.parentElement;
+ }
+
+ return overflowCausingChild;
+ });
+
+ return this.attachElements(overflowCausingElements);
+ }
+
+ /**
+ * Return the scrollable ancestor node which has overflow because of the given node.
+ *
+ * @param {NodeActor} overflowCausingNode
+ */
+ getScrollableAncestorNode(overflowCausingNode) {
+ if (
+ isNodeDead(overflowCausingNode) ||
+ !this.overflowCausingElementsMap.has(overflowCausingNode.rawNode)
+ ) {
+ return null;
+ }
+
+ return this.overflowCausingElementsMap.get(overflowCausingNode.rawNode);
+ }
+}
+
+exports.WalkerActor = WalkerActor;