utils.js (18259B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 "use strict"; 6 7 loader.lazyRequireGetter( 8 this, 9 "colorUtils", 10 "resource://devtools/shared/css/color.js", 11 true 12 ); 13 loader.lazyRequireGetter( 14 this, 15 "AsyncUtils", 16 "resource://devtools/shared/async-utils.js" 17 ); 18 loader.lazyRequireGetter(this, "flags", "resource://devtools/shared/flags.js"); 19 loader.lazyRequireGetter( 20 this, 21 "DevToolsUtils", 22 "resource://devtools/shared/DevToolsUtils.js" 23 ); 24 loader.lazyRequireGetter( 25 this, 26 "nodeFilterConstants", 27 "resource://devtools/shared/dom-node-filter-constants.js" 28 ); 29 loader.lazyRequireGetter( 30 this, 31 "getAdjustedQuads", 32 "resource://devtools/shared/layout/utils.js", 33 true 34 ); 35 loader.lazyRequireGetter( 36 this, 37 "CssLogic", 38 "resource://devtools/server/actors/inspector/css-logic.js", 39 true 40 ); 41 loader.lazyRequireGetter( 42 this, 43 "getBackgroundFor", 44 "resource://devtools/server/actors/accessibility/audit/contrast.js", 45 true 46 ); 47 loader.lazyRequireGetter( 48 this, 49 ["loadSheetForBackgroundCalculation", "removeSheetForBackgroundCalculation"], 50 "resource://devtools/server/actors/utils/accessibility.js", 51 true 52 ); 53 loader.lazyRequireGetter( 54 this, 55 "getTextProperties", 56 "resource://devtools/shared/accessibility.js", 57 true 58 ); 59 60 const XHTML_NS = "http://www.w3.org/1999/xhtml"; 61 const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; 62 const IMAGE_FETCHING_TIMEOUT = 500; 63 64 /** 65 * Returns the properly cased version of the node's tag name, which can be 66 * used when displaying said name in the UI. 67 * 68 * @param {Node} rawNode 69 * Node for which we want the display name 70 * @return {string} 71 * Properly cased version of the node tag name 72 */ 73 const getNodeDisplayName = function (rawNode) { 74 const { implementedPseudoElement } = rawNode; 75 if (implementedPseudoElement) { 76 if ( 77 implementedPseudoElement.startsWith("::view-transition") && 78 rawNode.hasAttribute("name") 79 ) { 80 return `${implementedPseudoElement}(${rawNode.getAttribute("name")})`; 81 } 82 83 return implementedPseudoElement; 84 } 85 86 if (rawNode.nodeName && !rawNode.localName) { 87 // The localName & prefix APIs have been moved from the Node interface to the Element 88 // interface. Use Node.nodeName as a fallback. 89 return rawNode.nodeName; 90 } 91 return (rawNode.prefix ? rawNode.prefix + ":" : "") + rawNode.localName; 92 }; 93 94 /** 95 * Returns flex and grid information about a DOM node. 96 * In particular is it a grid flex/container and/or item? 97 * 98 * @param {DOMNode} node 99 * The node for which then information is required 100 * @return {object} 101 * An object like { grid: { isContainer, isItem }, flex: { isContainer, isItem } } 102 */ 103 function getNodeGridFlexType(node) { 104 return { 105 grid: getNodeGridType(node), 106 flex: getNodeFlexType(node), 107 }; 108 } 109 110 function getNodeFlexType(node) { 111 return { 112 isContainer: node.getAsFlexContainer && !!node.getAsFlexContainer(), 113 isItem: !!node.parentFlexElement, 114 }; 115 } 116 117 function getNodeGridType(node) { 118 return { 119 isContainer: node.hasGridFragments && node.hasGridFragments(), 120 isItem: !!findGridParentContainerForNode(node), 121 }; 122 } 123 124 function nodeDocument(node) { 125 if (Cu.isDeadWrapper(node)) { 126 return null; 127 } 128 return ( 129 node.ownerDocument || (node.nodeType == Node.DOCUMENT_NODE ? node : null) 130 ); 131 } 132 133 function isNodeDead(node) { 134 return !node || !node.rawNode || Cu.isDeadWrapper(node.rawNode); 135 } 136 137 function isInXULDocument(el) { 138 const doc = nodeDocument(el); 139 return doc?.documentElement && doc.documentElement.namespaceURI === XUL_NS; 140 } 141 142 /** 143 * This DeepTreeWalker filter skips whitespace text nodes and anonymous content (unless 144 * we want them visible in the markup view, e.g. ::before, ::after, ::marker, …), 145 * plus anonymous content in XUL document (needed to show all elements in the browser toolbox). 146 */ 147 function standardTreeWalkerFilter(node) { 148 // There are a few native anonymous content that we want to show in markup 149 if ( 150 node.nodeName === "_moz_generated_content_marker" || 151 node.nodeName === "_moz_generated_content_before" || 152 node.nodeName === "_moz_generated_content_after" || 153 node.nodeName === "_moz_generated_content_backdrop" 154 ) { 155 return nodeFilterConstants.FILTER_ACCEPT; 156 } 157 158 // Ignore empty whitespace text nodes that do not impact the layout. 159 if (isWhitespaceTextNode(node)) { 160 return nodeHasSize(node) 161 ? nodeFilterConstants.FILTER_ACCEPT 162 : nodeFilterConstants.FILTER_SKIP; 163 } 164 165 if (node.isNativeAnonymous && !isInXULDocument(node)) { 166 const nodeTypeAttribute = node.getAttribute && node.getAttribute("type"); 167 // The ::view-transition pseudo element node has a <div type=":-moz-snapshot-containing-block"> 168 // parent element that we don't want to display in the markup view. 169 // Instead, we want to directly display the ::view-transition pseudo-element. 170 if (nodeTypeAttribute === ":-moz-snapshot-containing-block") { 171 // FILTER_ACCEPT_CHILDREN means that the node won't be returned, but its children 172 // will be instead 173 return nodeFilterConstants.FILTER_ACCEPT_CHILDREN; 174 } 175 176 // Display all the ::view-transition* nodes 177 if (nodeTypeAttribute && nodeTypeAttribute.startsWith(":view-transition")) { 178 return nodeFilterConstants.FILTER_ACCEPT; 179 } 180 181 // Ignore all other native anonymous roots inside a non-XUL document. 182 // We need to do this to skip things like form controls, scrollbars, 183 // video controls, etc (see bug 1187482). 184 return nodeFilterConstants.FILTER_SKIP; 185 } 186 187 return nodeFilterConstants.FILTER_ACCEPT; 188 } 189 190 /** 191 * This DeepTreeWalker filter ignores anonymous content. 192 */ 193 function noAnonymousContentTreeWalkerFilter(node) { 194 // Ignore all native anonymous content inside a non-XUL document. 195 // We need to do this to skip things like form controls, scrollbars, 196 // video controls, etc (see bug 1187482). 197 if (!isInXULDocument(node) && node.isNativeAnonymous) { 198 return nodeFilterConstants.FILTER_SKIP; 199 } 200 201 return nodeFilterConstants.FILTER_ACCEPT; 202 } 203 /** 204 * This DeepTreeWalker filter is like standardTreeWalkerFilter except that 205 * it also includes all anonymous content (like internal form controls). 206 */ 207 function allAnonymousContentTreeWalkerFilter(node) { 208 // Ignore empty whitespace text nodes that do not impact the layout. 209 if (isWhitespaceTextNode(node)) { 210 return nodeHasSize(node) 211 ? nodeFilterConstants.FILTER_ACCEPT 212 : nodeFilterConstants.FILTER_SKIP; 213 } 214 return nodeFilterConstants.FILTER_ACCEPT; 215 } 216 217 /** 218 * Is the given node a text node composed of whitespace only? 219 * 220 * @param {DOMNode} node 221 * @return {boolean} 222 */ 223 function isWhitespaceTextNode(node) { 224 return node.nodeType == Node.TEXT_NODE && !/[^\s]/.exec(node.nodeValue); 225 } 226 227 /** 228 * Does the given node have non-0 width and height? 229 * 230 * @param {DOMNode} node 231 * @return {boolean} 232 */ 233 function nodeHasSize(node) { 234 if (!node.getBoxQuads) { 235 return false; 236 } 237 238 const quads = node.getBoxQuads({ 239 createFramesForSuppressedWhitespace: false, 240 }); 241 return quads.some(quad => { 242 const bounds = quad.getBounds(); 243 return bounds.width && bounds.height; 244 }); 245 } 246 247 /** 248 * Returns a promise that is settled once the given HTMLImageElement has 249 * finished loading. 250 * 251 * @param {HTMLImageElement} image - The image element. 252 * @param {number} timeout - Maximum amount of time the image is allowed to load 253 * before the waiting is aborted. Ignored if flags.testing is set. 254 * 255 * @return {Promise} that is fulfilled once the image has loaded. If the image 256 * fails to load or the load takes too long, the promise is rejected. 257 */ 258 function ensureImageLoaded(image, timeout) { 259 const { HTMLImageElement } = image.ownerGlobal; 260 if (!(image instanceof HTMLImageElement)) { 261 return Promise.reject("image must be an HTMLImageELement"); 262 } 263 264 if (image.complete) { 265 // The image has already finished loading. 266 return Promise.resolve(); 267 } 268 269 // This image is still loading. 270 const onLoad = AsyncUtils.listenOnce(image, "load"); 271 272 // Reject if loading fails. 273 const onError = AsyncUtils.listenOnce(image, "error").then(() => { 274 return Promise.reject("Image '" + image.src + "' failed to load."); 275 }); 276 277 // Don't timeout when testing. This is never settled. 278 let onAbort = new Promise(() => {}); 279 280 if (!flags.testing) { 281 // Tests are not running. Reject the promise after given timeout. 282 onAbort = DevToolsUtils.waitForTime(timeout).then(() => { 283 return Promise.reject("Image '" + image.src + "' took too long to load."); 284 }); 285 } 286 287 // See which happens first. 288 return Promise.race([onLoad, onError, onAbort]); 289 } 290 291 /** 292 * Given an <img> or <canvas> element, return the image data-uri. If @param node 293 * is an <img> element, the method waits a while for the image to load before 294 * the data is generated. If the image does not finish loading in a reasonable 295 * time (IMAGE_FETCHING_TIMEOUT milliseconds) the process aborts. 296 * 297 * @param {HTMLImageElement|HTMLCanvasElement} node - The <img> or <canvas> 298 * element, or Image() object. Other types cause the method to reject. 299 * @param {number} maxDim - Optionally pass a maximum size you want the longest 300 * side of the image to be resized to before getting the image data. 301 302 * @return {Promise} A promise that is fulfilled with an object containing the 303 * data-uri and size-related information: 304 * { data: "...", 305 * size: { 306 * naturalWidth: 400, 307 * naturalHeight: 300, 308 * resized: true } 309 * }. 310 * 311 * If something goes wrong, the promise is rejected. 312 */ 313 const imageToImageData = async function (node, maxDim) { 314 const { HTMLCanvasElement, HTMLImageElement } = node.ownerGlobal; 315 316 const isImg = node instanceof HTMLImageElement; 317 const isCanvas = node instanceof HTMLCanvasElement; 318 319 if (!isImg && !isCanvas) { 320 throw new Error("node is not a <canvas> or <img> element."); 321 } 322 323 if (isImg) { 324 // Ensure that the image is ready. 325 await ensureImageLoaded(node, IMAGE_FETCHING_TIMEOUT); 326 } 327 328 // Get the image resize ratio if a maxDim was provided 329 let resizeRatio = 1; 330 const imgWidth = node.naturalWidth || node.width; 331 const imgHeight = node.naturalHeight || node.height; 332 const imgMax = Math.max(imgWidth, imgHeight); 333 if (maxDim && imgMax > maxDim) { 334 resizeRatio = maxDim / imgMax; 335 } 336 337 // Extract the image data 338 let imageData; 339 // The image may already be a data-uri, in which case, save ourselves the 340 // trouble of converting via the canvas.drawImage.toDataURL method, but only 341 // if the image doesn't need resizing 342 if (isImg && node.src.startsWith("data:") && resizeRatio === 1) { 343 imageData = node.src; 344 } else { 345 // Create a canvas to copy the rawNode into and get the imageData from 346 const canvas = node.ownerDocument.createElementNS(XHTML_NS, "canvas"); 347 canvas.width = imgWidth * resizeRatio; 348 canvas.height = imgHeight * resizeRatio; 349 const ctx = canvas.getContext("2d"); 350 351 // Copy the rawNode image or canvas in the new canvas and extract data 352 ctx.drawImage(node, 0, 0, canvas.width, canvas.height); 353 imageData = canvas.toDataURL("image/png"); 354 } 355 356 return { 357 data: imageData, 358 size: { 359 naturalWidth: imgWidth, 360 naturalHeight: imgHeight, 361 resized: resizeRatio !== 1, 362 }, 363 }; 364 }; 365 366 /** 367 * Finds the computed background color of the closest parent with a set background color. 368 * 369 * @param {DOMNode} node 370 * Node for which we want to find closest background color. 371 * @return {string} 372 * String with the background color of the form rgba(r, g, b, a). Defaults to 373 * rgba(255, 255, 255, 1) if no background color is found. 374 */ 375 function getClosestBackgroundColor(node) { 376 let current = node; 377 378 while (current) { 379 const computedStyle = CssLogic.getComputedStyle(current); 380 if (computedStyle) { 381 const currentStyle = computedStyle.getPropertyValue("background-color"); 382 if (InspectorUtils.isValidCSSColor(currentStyle)) { 383 const currentCssColor = new colorUtils.CssColor(currentStyle); 384 if (!currentCssColor.isTransparent()) { 385 return currentCssColor.rgba; 386 } 387 } 388 } 389 390 current = current.parentNode; 391 } 392 393 return "rgba(255, 255, 255, 1)"; 394 } 395 396 /** 397 * Finds the background image of the closest parent where it is set. 398 * 399 * @param {DOMNode} node 400 * Node for which we want to find the background image. 401 * @return {string} 402 * String with the value of the background iamge property. Defaults to "none" if 403 * no background image is found. 404 */ 405 function getClosestBackgroundImage(node) { 406 let current = node; 407 408 while (current) { 409 const computedStyle = CssLogic.getComputedStyle(current); 410 if (computedStyle) { 411 const currentBackgroundImage = 412 computedStyle.getPropertyValue("background-image"); 413 if (currentBackgroundImage !== "none") { 414 return currentBackgroundImage; 415 } 416 } 417 418 current = current.parentNode; 419 } 420 421 return "none"; 422 } 423 424 /** 425 * If the provided node is a grid item, then return its parent grid. 426 * 427 * @param {DOMNode} node 428 * The node that is supposedly a grid item. 429 * @return {DOMNode|null} 430 * The parent grid if found, null otherwise. 431 */ 432 function findGridParentContainerForNode(node) { 433 try { 434 while ((node = node.parentNode)) { 435 const display = node.ownerGlobal.getComputedStyle(node).display; 436 437 if (display.includes("grid")) { 438 return node; 439 } else if (display === "contents") { 440 // Continue walking up the tree since the parent node is a content element. 441 continue; 442 } 443 444 break; 445 } 446 } catch (e) { 447 // Getting the parentNode can fail when the supplied node is in shadow DOM. 448 } 449 450 return null; 451 } 452 453 /** 454 * Finds the background color range for the parent of a single text node 455 * (i.e. for multi-colored backgrounds with gradients, images) or a single 456 * background color for single-colored backgrounds. Defaults to the closest 457 * background color if an error is encountered. 458 * 459 * @param {object} 460 * Node actor containing the following properties: 461 * {DOMNode} rawNode 462 * Node for which we want to calculate the color contrast. 463 * {WalkerActor} walker 464 * Walker actor used to check whether the node is the parent elm of a single text node. 465 * @return {object} 466 * Object with one or more of the following properties: 467 * {Array|null} value 468 * RGBA array for single-colored background. Null for multi-colored backgrounds. 469 * {Array|null} min 470 * RGBA array for the min luminance color in a multi-colored background. 471 * Null for single-colored backgrounds. 472 * {Array|null} max 473 * RGBA array for the max luminance color in a multi-colored background. 474 * Null for single-colored backgrounds. 475 */ 476 async function getBackgroundColor({ rawNode: node, walker }) { 477 // Fall back to calculating contrast against closest bg if: 478 // - not element node 479 // - more than one child 480 // Avoid calculating bounds and creating doc walker by returning early. 481 if ( 482 node.nodeType != Node.ELEMENT_NODE || 483 node.childNodes.length > 1 || 484 !node.firstChild 485 ) { 486 return { 487 value: getClosestBackgroundColorInRGBA(node), 488 }; 489 } 490 491 const quads = getAdjustedQuads(node.ownerGlobal, node.firstChild, "content"); 492 493 // Fall back to calculating contrast against closest bg if there are no bounds for text node. 494 // Avoid creating doc walker by returning early. 495 if (quads.length === 0 || !quads[0].bounds) { 496 return { 497 value: getClosestBackgroundColorInRGBA(node), 498 }; 499 } 500 501 const bounds = quads[0].bounds; 502 503 const docWalker = walker.getDocumentWalker(node); 504 const firstChild = docWalker.firstChild(); 505 506 // Fall back to calculating contrast against closest bg if: 507 // - more than one child 508 // - unique child is not a text node 509 if ( 510 !firstChild || 511 docWalker.nextSibling() || 512 firstChild.nodeType !== Node.TEXT_NODE 513 ) { 514 return { 515 value: getClosestBackgroundColorInRGBA(node), 516 }; 517 } 518 519 // Try calculating complex backgrounds for node 520 const win = node.ownerGlobal; 521 loadSheetForBackgroundCalculation(win); 522 const computedStyle = CssLogic.getComputedStyle(node); 523 const props = computedStyle ? getTextProperties(computedStyle) : null; 524 525 // Fall back to calculating contrast against closest bg if there are no text props. 526 if (!props) { 527 return { 528 value: getClosestBackgroundColorInRGBA(node), 529 }; 530 } 531 532 const bgColor = await getBackgroundFor(node, { 533 bounds, 534 win, 535 convertBoundsRelativeToViewport: false, 536 size: props.size, 537 isBoldText: props.isBoldText, 538 }); 539 removeSheetForBackgroundCalculation(win); 540 541 return ( 542 bgColor || { 543 value: getClosestBackgroundColorInRGBA(node), 544 } 545 ); 546 } 547 548 /** 549 * 550 * @param {DOMNode} node: The node we want the background color of 551 * @returns {Array[r,g,b,a]} 552 */ 553 function getClosestBackgroundColorInRGBA(node) { 554 const { r, g, b, a } = InspectorUtils.colorToRGBA( 555 getClosestBackgroundColor(node) 556 ); 557 return [r, g, b, a]; 558 } 559 /** 560 * Indicates if a document is ready (i.e. if it's not loading anymore) 561 * 562 * @param {HTMLDocument} document: The document we want to check 563 * @returns {boolean} 564 */ 565 function isDocumentReady(document) { 566 if (!document) { 567 return false; 568 } 569 570 const { readyState } = document; 571 if (readyState == "interactive" || readyState == "complete") { 572 return true; 573 } 574 575 // A document might stay forever in uninitialized state. 576 // If the target actor is not currently loading a document, 577 // assume the document is ready. 578 const webProgress = document.defaultView.docShell.QueryInterface( 579 Ci.nsIWebProgress 580 ); 581 return !webProgress.isLoadingDocument; 582 } 583 584 module.exports = { 585 allAnonymousContentTreeWalkerFilter, 586 isDocumentReady, 587 isWhitespaceTextNode, 588 findGridParentContainerForNode, 589 getBackgroundColor, 590 getClosestBackgroundColor, 591 getClosestBackgroundImage, 592 getNodeDisplayName, 593 getNodeGridFlexType, 594 imageToImageData, 595 isNodeDead, 596 nodeDocument, 597 standardTreeWalkerFilter, 598 noAnonymousContentTreeWalkerFilter, 599 };