diff --git a/devtools/shared/inspector/css-logic.js b/devtools/shared/inspector/css-logic.js index 20954a1c42bd..6b25dfc679fe 100644 --- a/devtools/shared/inspector/css-logic.js +++ b/devtools/shared/inspector/css-logic.js @@ -19,22 +19,6 @@ const MAX_DATA_URL_LENGTH = 40; const Services = require("Services"); -loader.lazyImporter( - this, - "findCssSelector", - "resource://gre/modules/css-selector.js" -); -loader.lazyImporter( - this, - "findAllCssSelectors", - "resource://gre/modules/css-selector.js" -); -loader.lazyImporter( - this, - "getCssPath", - "resource://gre/modules/css-selector.js" -); -loader.lazyImporter(this, "getXPath", "resource://gre/modules/css-selector.js"); loader.lazyRequireGetter( this, "getCSSLexer", @@ -47,7 +31,7 @@ loader.lazyRequireGetter( "devtools/shared/indentation", true ); - +const { getRootBindingParent } = require("devtools/shared/layout/utils"); const { LocalizationHelper } = require("devtools/shared/l10n"); const styleInspectorL10N = new LocalizationHelper( "devtools/shared/locales/styleinspector.properties" @@ -495,38 +479,6 @@ function prettifyCSS(text, ruleCount) { exports.prettifyCSS = prettifyCSS; -/** - * Find a unique CSS selector for a given element - * @returns a string such that ele.ownerDocument.querySelector(reply) === ele - * and ele.ownerDocument.querySelectorAll(reply).length === 1 - */ -exports.findCssSelector = findCssSelector; - -/** - * Retrieve the array of CSS selectors corresponding to the provided node. - * - * The selectors are ordered starting with the root document and ending with the deepest - * nested frame. Additional items are used if the node is inside a frame or a shadow root, - * each representing the CSS selector for finding the frame or root element in its parent - * document. - */ -exports.findAllCssSelectors = findAllCssSelectors; - -/** - * Get the full CSS path for a given element. - * @returns a string that can be used as a CSS selector for the element. It might not - * match the element uniquely. It does however, represent the full path from the root - * node to the element. - */ -exports.getCssPath = getCssPath; - -/** - * Get the xpath for a given element. - * @param {DomNode} ele - * @returns a string that can be used as an XPath to find the element uniquely. - */ -exports.getXPath = getXPath; - /** * Given a node, check to see if it is a ::marker, ::before, or ::after element. * If so, return the node that is accessible from within the document @@ -585,3 +537,298 @@ function hasVisitedState(node) { ); } exports.hasVisitedState = hasVisitedState; + +/** + * Return the node's parent shadow root if the node in shadow DOM, null + * otherwise. + */ +function getShadowRoot(node) { + const doc = node.ownerDocument; + if (!doc) { + return null; + } + + const parent = doc.getBindingParent(node); + const shadowRoot = parent && parent.openOrClosedShadowRoot; + if (shadowRoot) { + return shadowRoot; + } + + return null; +} + +/** + * Find the position of [element] in [nodeList]. + * @returns an index of the match, or -1 if there is no match + */ +function positionInNodeList(element, nodeList) { + for (let i = 0; i < nodeList.length; i++) { + if (element === nodeList[i]) { + return i; + } + } + return -1; +} + +/** + * For a provided node, find the appropriate container/node couple so that + * container.contains(node) and a CSS selector can be created from the + * container to the node. + */ +function findNodeAndContainer(node) { + const shadowRoot = getShadowRoot(node); + if (shadowRoot) { + // If the node is under a shadow root, the shadowRoot contains the node and + // we can find the node via shadowRoot.querySelector(path). + return { + containingDocOrShadow: shadowRoot, + node, + }; + } + + // Otherwise, get the root binding parent to get a non anonymous element that + // will be accessible from the ownerDocument. + const bindingParent = getRootBindingParent(node); + return { + containingDocOrShadow: bindingParent.ownerDocument, + node: bindingParent, + }; +} + +/** + * Find a unique CSS selector for a given element + * @returns a string such that: + * - ele.containingDocOrShadow.querySelector(reply) === ele + * - ele.containingDocOrShadow.querySelectorAll(reply).length === 1 + */ +const findCssSelector = function(ele) { + const { node, containingDocOrShadow } = findNodeAndContainer(ele); + ele = node; + + if (!containingDocOrShadow || !containingDocOrShadow.contains(ele)) { + // findCssSelector received element not inside container. + return ""; + } + + const cssEscape = ele.ownerGlobal.CSS.escape; + + // document.querySelectorAll("#id") returns multiple if elements share an ID + if ( + ele.id && + containingDocOrShadow.querySelectorAll("#" + cssEscape(ele.id)).length === 1 + ) { + return "#" + cssEscape(ele.id); + } + + // Inherently unique by tag name + const tagName = ele.localName; + if (tagName === "html") { + return "html"; + } + if (tagName === "head") { + return "head"; + } + if (tagName === "body") { + return "body"; + } + + // We might be able to find a unique class name + let selector, index, matches; + for (let i = 0; i < ele.classList.length; i++) { + // Is this className unique by itself? + selector = "." + cssEscape(ele.classList.item(i)); + matches = containingDocOrShadow.querySelectorAll(selector); + if (matches.length === 1) { + return selector; + } + // Maybe it's unique with a tag name? + selector = cssEscape(tagName) + selector; + matches = containingDocOrShadow.querySelectorAll(selector); + if (matches.length === 1) { + return selector; + } + // Maybe it's unique using a tag name and nth-child + index = positionInNodeList(ele, ele.parentNode.children) + 1; + selector = selector + ":nth-child(" + index + ")"; + matches = containingDocOrShadow.querySelectorAll(selector); + if (matches.length === 1) { + return selector; + } + } + + // Not unique enough yet. + index = positionInNodeList(ele, ele.parentNode.children) + 1; + selector = cssEscape(tagName) + ":nth-child(" + index + ")"; + if (ele.parentNode !== containingDocOrShadow) { + selector = findCssSelector(ele.parentNode) + " > " + selector; + } + return selector; +}; +exports.findCssSelector = findCssSelector; + +/** + * If the element is in a frame or under a shadowRoot, return the corresponding + * element. + */ +function getSelectorParent(node) { + const shadowRoot = getShadowRoot(node); + if (shadowRoot) { + // The element is in a shadowRoot, return the host component. + return shadowRoot.host; + } + + // Otherwise return the parent frameElement. + return node.ownerGlobal.frameElement; +} + +/** + * Retrieve the array of CSS selectors corresponding to the provided node. + * + * The selectors are ordered starting with the root document and ending with the deepest + * nested frame. Additional items are used if the node is inside a frame or a shadow root, + * each representing the CSS selector for finding the frame or root element in its parent + * document. + * + * This format is expected by DevTools in order to handle the Inspect Node context menu + * item. + * + * @param {node} + * The node for which the CSS selectors should be computed + * @return {Array} + * An array of CSS selectors to find the target node. Several selectors can be + * needed if the element is nested in frames and not directly in the root + * document. The selectors are ordered starting with the root document and + * ending with the deepest nested frame or shadow root. + */ +const findAllCssSelectors = function(node) { + const selectors = []; + while (node) { + selectors.unshift(findCssSelector(node)); + node = getSelectorParent(node); + } + + return selectors; +}; +exports.findAllCssSelectors = findAllCssSelectors; + +/** + * Get the full CSS path for a given element. + * + * @returns a string that can be used as a CSS selector for the element. It might not + * match the element uniquely. It does however, represent the full path from the root + * node to the element. + */ +function getCssPath(ele) { + const { node, containingDocOrShadow } = findNodeAndContainer(ele); + ele = node; + if (!containingDocOrShadow || !containingDocOrShadow.contains(ele)) { + // getCssPath received element not inside container. + return ""; + } + + const nodeGlobal = ele.ownerGlobal.Node; + + const getElementSelector = element => { + if (!element.localName) { + return ""; + } + + let label = + element.nodeName == element.nodeName.toUpperCase() + ? element.localName.toLowerCase() + : element.localName; + + if (element.id) { + label += "#" + element.id; + } + + if (element.classList) { + for (const cl of element.classList) { + label += "." + cl; + } + } + + return label; + }; + + const paths = []; + + while (ele) { + if (!ele || ele.nodeType !== nodeGlobal.ELEMENT_NODE) { + break; + } + + paths.splice(0, 0, getElementSelector(ele)); + ele = ele.parentNode; + } + + return paths.length ? paths.join(" ") : ""; +} +exports.getCssPath = getCssPath; + +/** + * Get the xpath for a given element. + * + * @param {DomNode} ele + * @returns a string that can be used as an XPath to find the element uniquely. + */ +function getXPath(ele) { + const { node, containingDocOrShadow } = findNodeAndContainer(ele); + ele = node; + if (!containingDocOrShadow || !containingDocOrShadow.contains(ele)) { + // getXPath received element not inside container. + return ""; + } + + // Create a short XPath for elements with IDs. + if (ele.id) { + return `//*[@id="${ele.id}"]`; + } + + // Otherwise walk the DOM up and create a part for each ancestor. + const parts = []; + + const nodeGlobal = ele.ownerGlobal.Node; + // Use nodeName (instead of localName) so namespace prefix is included (if any). + while (ele && ele.nodeType === nodeGlobal.ELEMENT_NODE) { + let nbOfPreviousSiblings = 0; + let hasNextSiblings = false; + + // Count how many previous same-name siblings the element has. + let sibling = ele.previousSibling; + while (sibling) { + // Ignore document type declaration. + if ( + sibling.nodeType !== nodeGlobal.DOCUMENT_TYPE_NODE && + sibling.nodeName == ele.nodeName + ) { + nbOfPreviousSiblings++; + } + + sibling = sibling.previousSibling; + } + + // Check if the element has at least 1 next same-name sibling. + sibling = ele.nextSibling; + while (sibling) { + if (sibling.nodeName == ele.nodeName) { + hasNextSiblings = true; + break; + } + sibling = sibling.nextSibling; + } + + const prefix = ele.prefix ? ele.prefix + ":" : ""; + const nth = + nbOfPreviousSiblings || hasNextSiblings + ? `[${nbOfPreviousSiblings + 1}]` + : ""; + + parts.push(prefix + ele.localName + nth); + + ele = ele.parentNode; + } + + return parts.length ? "/" + parts.reverse().join("/") : ""; +} +exports.getXPath = getXPath; diff --git a/devtools/shared/tests/mochitest/chrome.ini b/devtools/shared/tests/mochitest/chrome.ini index 8ca9df081472..026b575d3383 100644 --- a/devtools/shared/tests/mochitest/chrome.ini +++ b/devtools/shared/tests/mochitest/chrome.ini @@ -2,6 +2,7 @@ tags = devtools skip-if = os == 'android' +[test_css-logic-findCssSelector.html] [test_css-logic-getCssPath.html] [test_css-logic-getXPath.html] skip-if = os == 'linux' && debug # Bug 1205739 diff --git a/toolkit/modules/tests/chrome/test_findCssSelector.html b/devtools/shared/tests/mochitest/test_css-logic-findCssSelector.html similarity index 94% rename from toolkit/modules/tests/chrome/test_findCssSelector.html rename to devtools/shared/tests/mochitest/test_css-logic-findCssSelector.html index 93b66601edf8..f41f82854c95 100644 --- a/toolkit/modules/tests/chrome/test_findCssSelector.html +++ b/devtools/shared/tests/mochitest/test_css-logic-findCssSelector.html @@ -1,18 +1,15 @@ - - Test for Bug + Test for CSS logic helper