tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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;