markup.js (23810B)
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 const { 8 getCurrentZoom, 9 getWindowDimensions, 10 getViewportDimensions, 11 } = require("resource://devtools/shared/layout/utils.js"); 12 13 const lazyContainer = {}; 14 15 loader.lazyRequireGetter( 16 lazyContainer, 17 "CssLogic", 18 "resource://devtools/server/actors/inspector/css-logic.js", 19 true 20 ); 21 loader.lazyRequireGetter( 22 this, 23 "isDocumentReady", 24 "resource://devtools/server/actors/inspector/utils.js", 25 true 26 ); 27 28 exports.getComputedStyle = node => 29 lazyContainer.CssLogic.getComputedStyle(node); 30 31 exports.getBindingElementAndPseudo = node => 32 lazyContainer.CssLogic.getBindingElementAndPseudo(node); 33 34 exports.hasPseudoClassLock = (...args) => 35 InspectorUtils.hasPseudoClassLock(...args); 36 37 exports.addPseudoClassLock = (...args) => 38 InspectorUtils.addPseudoClassLock(...args); 39 40 exports.removePseudoClassLock = (...args) => 41 InspectorUtils.removePseudoClassLock(...args); 42 43 const SVG_NS = "http://www.w3.org/2000/svg"; 44 const XHTML_NS = "http://www.w3.org/1999/xhtml"; 45 const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; 46 const STYLESHEET_URI = 47 "resource://devtools-highlighter-styles/highlighters.css"; 48 49 /** 50 * Is this content window a XUL window? 51 * 52 * @param {Window} window 53 * @return {boolean} 54 */ 55 function isXUL(window) { 56 return window.document.documentElement?.namespaceURI === XUL_NS; 57 } 58 exports.isXUL = isXUL; 59 60 /** 61 * Returns true if a DOM node is "valid", where "valid" means that the node isn't a dead 62 * object wrapper, is still attached to a document, and is of a given type. 63 * 64 * @param {DOMNode} node 65 * @param {number} nodeType Optional, defaults to ELEMENT_NODE 66 * @return {boolean} 67 */ 68 function isNodeValid(node, nodeType = Node.ELEMENT_NODE) { 69 // Is it still alive? 70 if (!node || Cu.isDeadWrapper(node)) { 71 return false; 72 } 73 74 // Is it of the right type? 75 if (node.nodeType !== nodeType) { 76 return false; 77 } 78 79 // Is its document accessible? 80 const doc = node.nodeType === Node.DOCUMENT_NODE ? node : node.ownerDocument; 81 if (!doc || !doc.defaultView) { 82 return false; 83 } 84 85 // Is the node connected to the document? 86 if (!node.isConnected) { 87 return false; 88 } 89 90 return true; 91 } 92 exports.isNodeValid = isNodeValid; 93 94 /** 95 * Every highlighters should insert their markup content into the document's 96 * canvasFrame anonymous content container (see dom/webidl/Document.webidl). 97 * 98 * Since this container gets cleared when the document navigates, highlighters 99 * should use this helper to have their markup content automatically re-inserted 100 * in the new document. 101 * To retrieve the AnonymousContent instance, use the content getter. 102 */ 103 class CanvasFrameAnonymousContentHelper { 104 /** 105 * @param {HighlighterEnv} highlighterEnv 106 * The environemnt which windows will be used to insert the node. 107 * @param {Function} nodeBuilder 108 * A function that, when executed, returns a DOM node to be inserted into 109 * the canvasFrame. 110 * @param {object} options 111 * @param {string | undefined} options.contentRootHostClassName 112 * An optional class to add to the AnonymousContent root's host. 113 * @param {boolean} options.waitForDocumentToLoad 114 * Set to false to try to insert the anonymous content even if the document 115 * isn't loaded yet. Defaults to true. 116 */ 117 constructor( 118 highlighterEnv, 119 nodeBuilder, 120 { contentRootHostClassName, waitForDocumentToLoad = true } = {} 121 ) { 122 this.#highlighterEnv = highlighterEnv; 123 this.#nodeBuilder = nodeBuilder; 124 this.#waitForDocumentToLoad = !!waitForDocumentToLoad; 125 this.#contentRootHostClassName = contentRootHostClassName; 126 127 this.#highlighterEnv.on("window-ready", this.#onWindowReady); 128 } 129 130 #content; 131 #initialized; 132 #highlighterEnv; 133 #nodeBuilder; 134 #waitForDocumentToLoad; 135 #contentRootHostClassName; 136 #listeners = new Map(); 137 #elements = new Map(); 138 139 initialize() { 140 // #insert will resolve this promise once the markup is displayed 141 const { promise: onInitialized, resolve } = Promise.withResolvers(); 142 this.#initialized = resolve; 143 144 // Only try to create the highlighter when the document is loaded, 145 // otherwise, wait for the window-ready event to fire. 146 const doc = this.#highlighterEnv.document; 147 if ( 148 !this.#waitForDocumentToLoad || 149 isDocumentReady(doc) || 150 doc.readyState !== "uninitialized" 151 ) { 152 this.#insert(); 153 } 154 155 return onInitialized; 156 } 157 158 destroy() { 159 this.#remove(); 160 161 this.#highlighterEnv.off("window-ready", this.#onWindowReady); 162 this.#highlighterEnv = this.#nodeBuilder = this.#content = null; 163 this.anonymousContentDocument = null; 164 this.anonymousContentWindow = null; 165 this.pageListenerTarget = null; 166 167 this.#removeAllListeners(); 168 this.#elements.clear(); 169 } 170 171 async #insert() { 172 if (this.#waitForDocumentToLoad) { 173 await waitForContentLoaded(this.#highlighterEnv.window); 174 } 175 if (!this.#highlighterEnv) { 176 // CanvasFrameAnonymousContentHelper was already destroyed. 177 return; 178 } 179 180 // Highlighters are drawn inside the anonymous content of the 181 // highlighter environment document. 182 this.anonymousContentDocument = this.#highlighterEnv.document; 183 this.anonymousContentWindow = this.#highlighterEnv.window; 184 this.pageListenerTarget = this.#highlighterEnv.pageListenerTarget; 185 186 // It was stated that hidden documents don't accept 187 // `insertAnonymousContent` calls yet. That doesn't seems the case anymore, 188 // at least on desktop. Therefore, removing the code that was dealing with 189 // that scenario, fixes when we're adding anonymous content in a tab that 190 // is not the active one (see bug 1260043 and bug 1260044) 191 try { 192 this.#content = this.anonymousContentDocument.insertAnonymousContent(); 193 } catch (e) { 194 // If the `insertAnonymousContent` fails throwing a `NS_ERROR_UNEXPECTED`, it means 195 // we don't have access to a `CustomContentContainer` yet (see bug 1365075). 196 // At this point, it could only happen on document's interactive state, and we 197 // need to wait until the `complete` state before inserting the anonymous content 198 // again. 199 if ( 200 e.result === Cr.NS_ERROR_UNEXPECTED && 201 this.anonymousContentDocument.readyState === "interactive" 202 ) { 203 // The next state change will be "complete" since the current is "interactive" 204 await new Promise(resolve => { 205 this.anonymousContentDocument.addEventListener( 206 "readystatechange", 207 resolve, 208 { once: true } 209 ); 210 }); 211 this.#content = this.anonymousContentDocument.insertAnonymousContent(); 212 } else { 213 throw e; 214 } 215 } 216 217 // Use createElementNS to make sure this is an HTML element. 218 // Document.createElement's behavior is different between SVG and HTML 219 // documents, see bug 1850007. 220 const link = this.anonymousContentDocument.createElementNS( 221 XHTML_NS, 222 "link" 223 ); 224 link.href = STYLESHEET_URI; 225 link.rel = "stylesheet"; 226 this.#content.root.appendChild(link); 227 this.#content.root.appendChild(this.#nodeBuilder()); 228 229 if (this.#contentRootHostClassName) { 230 this.#content.root.host.classList.add(this.#contentRootHostClassName); 231 } 232 233 this.#initialized(); 234 } 235 236 #remove() { 237 try { 238 this.anonymousContentDocument.removeAnonymousContent(this.#content); 239 } catch (e) { 240 // If the current window isn't the one the content was inserted into, this 241 // will fail, but that's fine. 242 } 243 } 244 245 /** 246 * The "window-ready" event can be triggered when: 247 * - a new window is created 248 * - a window is unfrozen from bfcache 249 * - when first attaching to a page 250 * - when swapping frame loaders (moving tabs, toggling RDM) 251 */ 252 #onWindowReady = ({ isTopLevel }) => { 253 if (isTopLevel) { 254 this.#removeAllListeners(); 255 this.#elements.clear(); 256 this.#insert(); 257 } 258 }; 259 260 #getNodeById(id) { 261 return this.content?.root.getElementById(id); 262 } 263 264 getBoundingClientRect(id) { 265 const node = this.#getNodeById(id); 266 if (!node) { 267 return null; 268 } 269 return node.getBoundingClientRect(); 270 } 271 272 getComputedStylePropertyValue(id, property) { 273 const node = this.#getNodeById(id); 274 if (!node) { 275 return null; 276 } 277 return this.anonymousContentWindow 278 .getComputedStyle(node) 279 .getPropertyValue(property); 280 } 281 282 getTextContentForElement(id) { 283 return this.#getNodeById(id)?.textContent; 284 } 285 286 setTextContentForElement(id, text) { 287 const node = this.#getNodeById(id); 288 if (!node) { 289 return; 290 } 291 node.textContent = text; 292 } 293 294 setAttributeForElement(id, name, value) { 295 this.#getNodeById(id)?.setAttribute(name, value); 296 } 297 298 getAttributeForElement(id, name) { 299 return this.#getNodeById(id)?.getAttribute(name); 300 } 301 302 removeAttributeForElement(id, name) { 303 this.#getNodeById(id)?.removeAttribute(name); 304 } 305 306 hasAttributeForElement(id, name) { 307 return typeof this.getAttributeForElement(id, name) === "string"; 308 } 309 310 getCanvasContext(id, type = "2d") { 311 return this.#getNodeById(id)?.getContext(type); 312 } 313 314 /** 315 * Add an event listener to one of the elements inserted in the canvasFrame 316 * native anonymous container. 317 * Like other methods in this helper, this requires the ID of the element to 318 * be passed in. 319 * 320 * Note that if the content page navigates, the event listeners won't be 321 * added again. 322 * 323 * Also note that unlike traditional DOM events, the events handled by 324 * listeners added here will propagate through the document only through 325 * bubbling phase, so the useCapture parameter isn't supported. 326 * It is possible however to call e.stopPropagation() to stop the bubbling. 327 * 328 * IMPORTANT: the chrome-only canvasFrame insertion API takes great care of 329 * not leaking references to inserted elements to chrome JS code. That's 330 * because otherwise, chrome JS code could freely modify native anon elements 331 * inside the canvasFrame and probably change things that are assumed not to 332 * change by the C++ code managing this frame. 333 * See https://wiki.mozilla.org/DevTools/Highlighter#The_AnonymousContent_API 334 * Unfortunately, the inserted nodes are still available via 335 * event.originalTarget, and that's what the event handler here uses to check 336 * that the event actually occured on the right element, but that also means 337 * consumers of this code would be able to access the inserted elements. 338 * Therefore, the originalTarget property will be nullified before the event 339 * is passed to your handler. 340 * 341 * IMPL DETAIL: A single event listener is added per event types only, at 342 * browser level and if the event originalTarget is found to have the provided 343 * ID, the callback is executed (and then IDs of parent nodes of the 344 * originalTarget are checked too). 345 * 346 * @param {string} id 347 * @param {string} type 348 * @param {Function} handler 349 */ 350 addEventListenerForElement(id, type, handler) { 351 if (typeof id !== "string") { 352 throw new Error( 353 "Expected a string ID in addEventListenerForElement but" + " got: " + id 354 ); 355 } 356 357 // If no one is listening for this type of event yet, add one listener. 358 if (!this.#listeners.has(type)) { 359 const target = this.pageListenerTarget; 360 target.addEventListener(type, this, true); 361 // Each type entry in the map is a map of ids:handlers. 362 this.#listeners.set(type, new Map()); 363 } 364 365 const listeners = this.#listeners.get(type); 366 listeners.set(id, handler); 367 } 368 369 /** 370 * Remove an event listener from one of the elements inserted in the 371 * canvasFrame native anonymous container. 372 * 373 * @param {string} id 374 * @param {string} type 375 */ 376 removeEventListenerForElement(id, type) { 377 const listeners = this.#listeners.get(type); 378 if (!listeners) { 379 return; 380 } 381 listeners.delete(id); 382 383 // If no one is listening for event type anymore, remove the listener. 384 if (!this.#listeners.has(type)) { 385 const target = this.pageListenerTarget; 386 target.removeEventListener(type, this, true); 387 } 388 } 389 390 handleEvent(event) { 391 const listeners = this.#listeners.get(event.type); 392 if (!listeners) { 393 return; 394 } 395 396 // Hide the originalTarget property to avoid exposing references to native 397 // anonymous elements. See addEventListenerForElement's comment. 398 let isPropagationStopped = false; 399 const eventProxy = new Proxy(event, { 400 get: (obj, name) => { 401 if (name === "originalTarget") { 402 return null; 403 } else if (name === "stopPropagation") { 404 return () => { 405 isPropagationStopped = true; 406 }; 407 } 408 return obj[name]; 409 }, 410 }); 411 412 // Start at originalTarget, bubble through ancestors and call handlers when 413 // needed. 414 let node = event.originalTarget; 415 while (node) { 416 const handler = listeners.get(node.id); 417 if (handler) { 418 handler(eventProxy, node.id); 419 if (isPropagationStopped) { 420 break; 421 } 422 } 423 node = node.parentNode; 424 } 425 } 426 427 #removeAllListeners() { 428 if (this.pageListenerTarget) { 429 const target = this.pageListenerTarget; 430 for (const [type] of this.#listeners) { 431 target.removeEventListener(type, this, true); 432 } 433 } 434 this.#listeners.clear(); 435 } 436 437 getElement(id) { 438 if (this.#elements.has(id)) { 439 return this.#elements.get(id); 440 } 441 442 const element = { 443 getTextContent: () => this.getTextContentForElement(id), 444 setTextContent: text => this.setTextContentForElement(id, text), 445 setAttribute: (name, val) => this.setAttributeForElement(id, name, val), 446 getAttribute: name => this.getAttributeForElement(id, name), 447 removeAttribute: name => this.removeAttributeForElement(id, name), 448 hasAttribute: name => this.hasAttributeForElement(id, name), 449 getCanvasContext: type => this.getCanvasContext(id, type), 450 addEventListener: (type, handler) => { 451 return this.addEventListenerForElement(id, type, handler); 452 }, 453 removeEventListener: (type, handler) => { 454 return this.removeEventListenerForElement(id, type, handler); 455 }, 456 computedStyle: { 457 getPropertyValue: property => 458 this.getComputedStylePropertyValue(id, property), 459 }, 460 classList: this.#getNodeById(id)?.classList, 461 }; 462 463 this.#elements.set(id, element); 464 465 return element; 466 } 467 468 get content() { 469 if (!this.#content || Cu.isDeadWrapper(this.#content)) { 470 return null; 471 } 472 return this.#content; 473 } 474 475 /** 476 * The canvasFrame anonymous content container gets zoomed in/out with the 477 * page. If this is unwanted, i.e. if you want the inserted element to remain 478 * unzoomed, then this method can be used. 479 * 480 * Consumers of the CanvasFrameAnonymousContentHelper should call this method, 481 * it isn't executed automatically. Typically, AutoRefreshHighlighter can call 482 * it when _update is executed. 483 * 484 * The matching element will be scaled down or up by 1/zoomLevel (using css 485 * transform) to cancel the current zoom. The element's width and height 486 * styles will also be set according to the scale. Finally, the element's 487 * position will be set as absolute. 488 * 489 * Note that if the matching element already has an inline style attribute, it 490 * *won't* be preserved. 491 * 492 * @param {DOMNode} node This node is used to determine which container window 493 * should be used to read the current zoom value. 494 * @param {string} id The ID of the root element inserted with this API. 495 */ 496 scaleRootElement(node, id) { 497 const boundaryWindow = this.#highlighterEnv.window; 498 const zoom = getCurrentZoom(node); 499 // Hide the root element and force the reflow in order to get the proper window's 500 // dimensions without increasing them. 501 const root = this.#getNodeById(id); 502 root.style.display = "none"; 503 node.offsetWidth; 504 505 let { width, height } = getWindowDimensions(boundaryWindow); 506 let value = ""; 507 508 if (zoom !== 1) { 509 value = `transform-origin:top left; transform:scale(${1 / zoom}); `; 510 width *= zoom; 511 height *= zoom; 512 } 513 514 value += `position:absolute; width:${width}px;height:${height}px; overflow:hidden;`; 515 root.style = value; 516 } 517 518 /** 519 * Helper function that creates SVG DOM nodes. 520 * 521 * @param {object} Options for the node include: 522 * - nodeType: the type of node, defaults to "box". 523 * - attributes: a {name:value} object to be used as attributes for the node. 524 * - parent: if provided, the newly created element will be appended to this 525 * node. 526 */ 527 createSVGNode(options) { 528 if (!options.nodeType) { 529 options.nodeType = "box"; 530 } 531 532 options.namespace = SVG_NS; 533 534 return this.createNode(options); 535 } 536 537 /** 538 * Helper function that creates DOM nodes. 539 * 540 * @param {object} Options for the node include: 541 * - nodeType: the type of node, defaults to "div". 542 * - namespace: the namespace to use to create the node, defaults to XHTML namespace. 543 * - attributes: a {name:value} object to be used as attributes for the node. 544 * - parent: if provided, the newly created element will be appended to this 545 * node. 546 * - text: if provided, set the text content of the element. 547 */ 548 createNode(options) { 549 const type = options.nodeType || "div"; 550 const namespace = options.namespace || XHTML_NS; 551 const doc = this.anonymousContentDocument; 552 553 const node = doc.createElementNS(namespace, type); 554 555 for (const name in options.attributes || {}) { 556 node.setAttribute(name, options.attributes[name]); 557 } 558 559 if (options.parent) { 560 options.parent.appendChild(node); 561 } 562 563 if (options.text) { 564 node.append(options.text); 565 } 566 567 return node; 568 } 569 } 570 571 exports.CanvasFrameAnonymousContentHelper = CanvasFrameAnonymousContentHelper; 572 573 /** 574 * Wait for document readyness. 575 * 576 * @param {object} iframeOrWindow 577 * IFrame or Window for which the content should be loaded. 578 */ 579 function waitForContentLoaded(iframeOrWindow) { 580 let loadEvent = "DOMContentLoaded"; 581 // If we are waiting for an iframe to load and it is for a XUL window 582 // highlighter that is not browser toolbox, we must wait for IFRAME's "load". 583 if ( 584 iframeOrWindow.contentWindow && 585 iframeOrWindow.ownerGlobal !== 586 iframeOrWindow.contentWindow.browsingContext.topChromeWindow 587 ) { 588 loadEvent = "load"; 589 } 590 591 const doc = iframeOrWindow.contentDocument || iframeOrWindow.document; 592 if (isDocumentReady(doc)) { 593 return Promise.resolve(); 594 } 595 596 return new Promise(resolve => { 597 iframeOrWindow.addEventListener(loadEvent, resolve, { once: true }); 598 }); 599 } 600 601 /** 602 * Move the infobar to the right place in the highlighter. This helper method is utilized 603 * in both css-grid.js and box-model.js to help position the infobar in an appropriate 604 * space over the highlighted node element or grid area. The infobar is used to display 605 * relevant information about the highlighted item (ex, node or grid name and dimensions). 606 * 607 * This method will first try to position the infobar to top or bottom of the container 608 * such that it has enough space for the height of the infobar. Afterwards, it will try 609 * to horizontally center align with the container element if possible. 610 * 611 * @param {DOMNode} container 612 * The container element which will be used to position the infobar. 613 * @param {object} bounds 614 * The content bounds of the container element. 615 * @param {Window} win 616 * The window object. 617 * @param {object} [options={}] 618 * Advanced options for the infobar. 619 * @param {string} options.position 620 * Force the infobar to be displayed either on "top" or "bottom". Any other value 621 * will be ingnored. 622 */ 623 function moveInfobar(container, bounds, win, options = {}) { 624 const zoom = getCurrentZoom(win); 625 const viewport = getViewportDimensions(win); 626 627 const { computedStyle } = container; 628 629 const margin = 2; 630 const arrowSize = parseFloat( 631 computedStyle.getPropertyValue("--highlighter-bubble-arrow-size") 632 ); 633 const containerHeight = parseFloat(computedStyle.getPropertyValue("height")); 634 const containerWidth = parseFloat(computedStyle.getPropertyValue("width")); 635 const containerHalfWidth = containerWidth / 2; 636 637 const viewportWidth = viewport.width * zoom; 638 const viewportHeight = viewport.height * zoom; 639 let { pageXOffset, pageYOffset } = win; 640 641 pageYOffset *= zoom; 642 pageXOffset *= zoom; 643 644 // Defines the boundaries for the infobar. 645 const topBoundary = margin; 646 const bottomBoundary = viewportHeight - containerHeight - margin - 1; 647 const leftBoundary = containerHalfWidth + margin; 648 const rightBoundary = viewportWidth - containerHalfWidth - margin; 649 650 // Set the default values. 651 let top = bounds.y - containerHeight - arrowSize; 652 const bottom = bounds.bottom + margin + arrowSize; 653 let left = bounds.x + bounds.width / 2; 654 let isOverlapTheNode = false; 655 let positionAttribute = "top"; 656 let position = "absolute"; 657 658 // Here we start the math. 659 // We basically want to position absolutely the infobar, except when is pointing to a 660 // node that is offscreen or partially offscreen, in a way that the infobar can't 661 // be placed neither on top nor on bottom. 662 // In such cases, the infobar will overlap the node, and to limit the latency given 663 // by APZ (See Bug 1312103) it will be positioned as "fixed". 664 // It's a sort of "position: sticky" (but positioned as absolute instead of relative). 665 const canBePlacedOnTop = top >= pageYOffset; 666 const canBePlacedOnBottom = bottomBoundary + pageYOffset - bottom > 0; 667 const forcedOnTop = options.position === "top"; 668 const forcedOnBottom = options.position === "bottom"; 669 670 if ( 671 (!canBePlacedOnTop && canBePlacedOnBottom && !forcedOnTop) || 672 forcedOnBottom 673 ) { 674 top = bottom; 675 positionAttribute = "bottom"; 676 } 677 678 const isOffscreenOnTop = top < topBoundary + pageYOffset; 679 const isOffscreenOnBottom = top > bottomBoundary + pageYOffset; 680 const isOffscreenOnLeft = left < leftBoundary + pageXOffset; 681 const isOffscreenOnRight = left > rightBoundary + pageXOffset; 682 683 if (isOffscreenOnTop) { 684 top = topBoundary; 685 isOverlapTheNode = true; 686 } else if (isOffscreenOnBottom) { 687 top = bottomBoundary; 688 isOverlapTheNode = true; 689 } else if (isOffscreenOnLeft || isOffscreenOnRight) { 690 isOverlapTheNode = true; 691 top -= pageYOffset; 692 } 693 694 if (isOverlapTheNode) { 695 left = Math.min(Math.max(leftBoundary, left - pageXOffset), rightBoundary); 696 697 position = "fixed"; 698 container.setAttribute("hide-arrow", "true"); 699 } else { 700 position = "absolute"; 701 container.removeAttribute("hide-arrow"); 702 } 703 704 // We need to scale the infobar Independently from the highlighter's container; 705 // otherwise the `position: fixed` won't work, since "any value other than `none` for 706 // the transform, results in the creation of both a stacking context and a containing 707 // block. The object acts as a containing block for fixed positioned descendants." 708 // (See https://www.w3.org/TR/css-transforms-1/#transform-rendering) 709 // We also need to shift the infobar 50% to the left in order for it to appear centered 710 // on the element it points to. 711 container.setAttribute( 712 "style", 713 ` 714 position:${position}; 715 transform-origin: 0 0; 716 transform: scale(${1 / zoom}) translate(calc(${left}px - 50%), ${top}px)` 717 ); 718 719 container.setAttribute("position", positionAttribute); 720 } 721 exports.moveInfobar = moveInfobar;