570 lines
17 KiB
JavaScript
570 lines
17 KiB
JavaScript
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
|
|
|
"use strict";
|
|
|
|
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 uninitialized 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,
|
|
};
|