diff options
Diffstat (limited to 'devtools/server/actors/inspector/utils.js')
-rw-r--r-- | devtools/server/actors/inspector/utils.js | 587 |
1 files changed, 587 insertions, 0 deletions
diff --git a/devtools/server/actors/inspector/utils.js b/devtools/server/actors/inspector/utils.js new file mode 100644 index 0000000000..bb2f1322ef --- /dev/null +++ b/devtools/server/actors/inspector/utils.js @@ -0,0 +1,587 @@ +/* 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 { Cu, Ci } = require("chrome"); + +loader.lazyRequireGetter(this, "colorUtils", "devtools/shared/css/color", true); +loader.lazyRequireGetter(this, "AsyncUtils", "devtools/shared/async-utils"); +loader.lazyRequireGetter(this, "flags", "devtools/shared/flags"); +loader.lazyRequireGetter( + this, + "DevToolsUtils", + "devtools/shared/DevToolsUtils" +); +loader.lazyRequireGetter( + this, + "nodeFilterConstants", + "devtools/shared/dom-node-filter-constants" +); +loader.lazyRequireGetter( + this, + ["isNativeAnonymous", "getAdjustedQuads"], + "devtools/shared/layout/utils", + true +); +loader.lazyRequireGetter( + this, + "CssLogic", + "devtools/server/actors/inspector/css-logic", + true +); +loader.lazyRequireGetter( + this, + "getBackgroundFor", + "devtools/server/actors/accessibility/audit/contrast", + true +); +loader.lazyRequireGetter( + this, + ["loadSheetForBackgroundCalculation", "removeSheetForBackgroundCalculation"], + "devtools/server/actors/utils/accessibility", + true +); +loader.lazyRequireGetter( + this, + "getTextProperties", + "devtools/shared/accessibility", + 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 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 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; +} + +/** + * This DeepTreeWalker filter only accepts <scrollbar> anonymous content. + */ +function scrollbarTreeWalkerFilter(node) { + if (node.nodeName === "scrollbar" && nodeHasSize(node)) { + return nodeFilterConstants.FILTER_ACCEPT; + } + + return nodeFilterConstants.FILTER_SKIP; +} + +/** + * 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 (colorUtils.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: colorUtils.colorToRGBA( + getClosestBackgroundColor(node), + true, + true + ), + }; + } + + const bounds = getAdjustedQuads( + node.ownerGlobal, + node.firstChild, + "content" + )[0].bounds; + + // Fall back to calculating contrast against closest bg if there are no bounds for text node. + // Avoid creating doc walker by returning early. + if (!bounds) { + return { + value: colorUtils.colorToRGBA( + getClosestBackgroundColor(node), + true, + true + ), + }; + } + + 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: colorUtils.colorToRGBA( + getClosestBackgroundColor(node), + true, + true + ), + }; + } + + // 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: colorUtils.colorToRGBA( + getClosestBackgroundColor(node), + true, + true + ), + }; + } + + const bgColor = await getBackgroundFor(node, { + bounds, + win, + convertBoundsRelativeToViewport: false, + size: props.size, + isBoldText: props.isBoldText, + }); + removeSheetForBackgroundCalculation(win); + + return ( + bgColor || { + value: colorUtils.colorToRGBA( + getClosestBackgroundColor(node), + true, + true + ), + } + ); +} + +/** + * 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, + scrollbarTreeWalkerFilter, + standardTreeWalkerFilter, + noAnonymousContentTreeWalkerFilter, +}; |