tor-browser

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

node-picker.js (17090B)


      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  "isRemoteBrowserElement",
     10  "resource://devtools/shared/layout/utils.js",
     11  true
     12 );
     13 loader.lazyRequireGetter(
     14  this,
     15  "HighlighterEnvironment",
     16  "resource://devtools/server/actors/highlighters.js",
     17  true
     18 );
     19 loader.lazyRequireGetter(
     20  this,
     21  "RemoteNodePickerNotice",
     22  "resource://devtools/server/actors/highlighters/remote-node-picker-notice.js",
     23  true
     24 );
     25 
     26 const IS_OSX = Services.appinfo.OS === "Darwin";
     27 
     28 class NodePicker {
     29  #eventListenersAbortController;
     30  #remoteNodePickerNoticeHighlighter;
     31 
     32  constructor(walker, targetActor) {
     33    this._walker = walker;
     34    this._targetActor = targetActor;
     35 
     36    this._isPicking = false;
     37    this._hoveredNode = null;
     38    this._currentNode = null;
     39 
     40    this._onHovered = this._onHovered.bind(this);
     41    this._onKeyDown = this._onKeyDown.bind(this);
     42    this._onKeyUp = this._onKeyUp.bind(this);
     43    this._onPick = this._onPick.bind(this);
     44    this._onSuppressedEvent = this._onSuppressedEvent.bind(this);
     45    this._preventContentEvent = this._preventContentEvent.bind(this);
     46  }
     47 
     48  get remoteNodePickerNoticeHighlighter() {
     49    if (!this.#remoteNodePickerNoticeHighlighter) {
     50      const env = new HighlighterEnvironment();
     51      env.initFromTargetActor(this._targetActor);
     52      this.#remoteNodePickerNoticeHighlighter = new RemoteNodePickerNotice(env);
     53    }
     54 
     55    return this.#remoteNodePickerNoticeHighlighter;
     56  }
     57 
     58  /**
     59   * Find the element from the passed mouse event. If shift isn't pressed (or shiftKey is false)
     60   * this will ignore all elements who can't consume pointer events (e.g. with inert attribute
     61   * or `pointer-events: none` style).
     62   *
     63   * @param {MouseEvent} event
     64   * @param {boolean} shiftKey: If passed, will override event.shiftKey
     65   * @returns {object} An object compatible with the disconnectedNode type.
     66   */
     67  _findAndAttachElement(event, shiftKey = event.shiftKey) {
     68    // originalTarget allows access to the "real" element before any retargeting
     69    // is applied, such as in the case of XBL anonymous elements.  See also
     70    // https://developer.mozilla.org/docs/XBL/XBL_1.0_Reference/Anonymous_Content#Event_Flow_and_Targeting
     71    let node = event.originalTarget || event.target;
     72 
     73    // When holding the Shift key, search for the element at the mouse position (as opposed
     74    // to the event target). This would make it possible to pick nodes for which we won't
     75    // get events for  (e.g. elements with `pointer-events: none`).
     76    if (shiftKey) {
     77      node = this._findNodeAtMouseEventPosition(event) || node;
     78    }
     79 
     80    return this._walker.attachElement(node);
     81  }
     82 
     83  /**
     84   * Return the topmost visible element located at the event mouse position. This is
     85   * different from retrieving the event target as it allows to retrieve elements for which
     86   * we wouldn't have mouse event triggered (e.g. elements with `pointer-events: none`)
     87   *
     88   * @param {MouseEvent} event
     89   * @returns HTMLElement
     90   */
     91  _findNodeAtMouseEventPosition(event) {
     92    const win = this._targetActor.window;
     93    const winUtils = win.windowUtils;
     94    const rectSize = 1;
     95    const elements = Array.from(
     96      winUtils.nodesFromRect(
     97        // aX
     98        event.clientX,
     99        // aY
    100        event.clientY,
    101        // aTopSize
    102        rectSize,
    103        // aRightSize
    104        rectSize,
    105        // aBottomSize
    106        rectSize,
    107        // aLeftSize
    108        rectSize,
    109        // aIgnoreRootScrollFrame
    110        true,
    111        // aFlushLayout
    112        false,
    113        // aOnlyVisible
    114        true,
    115        // aTransparencyThreshold
    116        1
    117      )
    118    ).filter(element => {
    119      // Strip out text nodes, we want to highlight Elements only
    120      return !win.Text.isInstance(element);
    121    });
    122 
    123    if (!elements.length) {
    124      return null;
    125    }
    126 
    127    if (elements.length === 1) {
    128      return elements[0];
    129    }
    130 
    131    // Let's return the first element that we find that is not a parent of another matching
    132    // element, so we get the "deepest" element possible.
    133    // At this points, we have at least 2 elements and are guaranteed to find an element
    134    // which is not the parent of any other ones.
    135    return elements.find(
    136      element => !elements.some(e => element !== e && element.contains(e))
    137    );
    138  }
    139 
    140  /**
    141   * Returns `true` if the event was dispatched from a window included in
    142   * the current highlighter environment; or if the highlighter environment has
    143   * chrome privileges
    144   *
    145   * @param {Event} event
    146   *          The event to allow
    147   * @return {boolean}
    148   */
    149  _isEventAllowed({ view }) {
    150    // Allow "non multiprocess" browser toolbox to inspect documents loaded in the parent
    151    // process (e.g. about:robots)
    152    if (this._targetActor.window.isChromeWindow) {
    153      return true;
    154    }
    155 
    156    return this._targetActor.windows.includes(view);
    157  }
    158 
    159  /**
    160   * Returns true if the passed event original target is in the RemoteNodePickerNotice.
    161   *
    162   * @param {Event} event
    163   * @returns {boolean}
    164   */
    165  _isEventInRemoteNodePickerNotice(event) {
    166    return (
    167      this.#remoteNodePickerNoticeHighlighter &&
    168      event.originalTarget?.closest?.(
    169        `#${this.#remoteNodePickerNoticeHighlighter.rootElementId}`
    170      )
    171    );
    172  }
    173 
    174  /**
    175   * Pick a node on click.
    176   *
    177   * This method doesn't respond anything interesting, however, it starts
    178   * mousemove, and click listeners on the content document to fire
    179   * events and let connected clients know when nodes are hovered over or
    180   * clicked.
    181   *
    182   * Once a node is picked, events will cease, and listeners will be removed.
    183   */
    184  _onPick(event) {
    185    // If the picked node is a remote frame, then we need to let the event through
    186    // since there's a highlighter actor in that sub-frame also picking.
    187    if (isRemoteBrowserElement(event.target)) {
    188      return;
    189    }
    190 
    191    this._preventContentEvent(event);
    192    if (!this._isEventAllowed(event)) {
    193      return;
    194    }
    195 
    196    // If the click was done inside the node picker notice highlighter (e.g. clicking the
    197    // close button), directly call its `onClick` method, as it doesn't have event listeners
    198    // itself, to avoid managing events (+ suppressedEventListeners) for the same target
    199    // from different places.
    200    if (this._isEventInRemoteNodePickerNotice(event)) {
    201      this.#remoteNodePickerNoticeHighlighter.onClick(event);
    202      return;
    203    }
    204 
    205    // If Ctrl (Or Cmd on OSX) is pressed, this is only a preview click.
    206    // Send the event to the client, but don't stop picking.
    207    if ((IS_OSX && event.metaKey) || (!IS_OSX && event.ctrlKey)) {
    208      this._walker.emit(
    209        "picker-node-previewed",
    210        this._findAndAttachElement(event)
    211      );
    212      return;
    213    }
    214 
    215    this._stopPicking();
    216 
    217    if (!this._currentNode) {
    218      this._currentNode = this._findAndAttachElement(event);
    219    }
    220 
    221    this._walker.emit("picker-node-picked", this._currentNode);
    222  }
    223 
    224  /**
    225   * mousemove event handler
    226   *
    227   * @param {MouseEvent} event
    228   * @param {boolean} shiftKeyOverride: If passed, will override event.shiftKey in _findAndAttachElement
    229   */
    230  _onHovered(event, shiftKeyOverride) {
    231    // If the hovered node is a remote frame, then we need to let the event through
    232    // since there's a highlighter actor in that sub-frame also picking.
    233    if (isRemoteBrowserElement(event.target)) {
    234      return;
    235    }
    236 
    237    this._preventContentEvent(event);
    238    if (!this._isEventAllowed(event)) {
    239      return;
    240    }
    241 
    242    this._lastMouseMoveEvent = event;
    243 
    244    // Always call remoteNodePickerNotice handleHoveredElement so the hover state can be updated
    245    // (it doesn't have its own event listeners to avoid managing events and suppressed
    246    // events for the same target from different places).
    247    if (this.#remoteNodePickerNoticeHighlighter) {
    248      this.#remoteNodePickerNoticeHighlighter.handleHoveredElement(event);
    249      if (this._isEventInRemoteNodePickerNotice(event)) {
    250        return;
    251      }
    252    }
    253 
    254    this._currentNode = this._findAndAttachElement(event, shiftKeyOverride);
    255    if (this._hoveredNode !== this._currentNode.node) {
    256      this._walker.emit("picker-node-hovered", this._currentNode);
    257      this._hoveredNode = this._currentNode.node;
    258    }
    259  }
    260 
    261  // eslint-disable-next-line complexity
    262  _onKeyDown(event) {
    263    if (!this._isPicking) {
    264      return;
    265    }
    266 
    267    this._preventContentEvent(event);
    268    if (!this._isEventAllowed(event)) {
    269      return;
    270    }
    271 
    272    // Handle keys which don't require a currently picked node:
    273    // - ENTER/CARRIAGE_RETURN: Picks currentNode
    274    // - ESC/CTRL+SHIFT+C: Cancels picker, picks currentNode
    275    // - SHIFT: Trigger onHover, handling `pointer-events: none` nodes
    276    switch (event.keyCode) {
    277      // Select the element.
    278      case event.DOM_VK_RETURN:
    279        this._onPick(event);
    280        return;
    281 
    282      // Cancel pick mode.
    283      case event.DOM_VK_ESCAPE:
    284        this.cancelPick();
    285        this._walker.emit("picker-node-canceled");
    286        return;
    287      case event.DOM_VK_C: {
    288        const { altKey, ctrlKey, metaKey, shiftKey } = event;
    289 
    290        if (
    291          (IS_OSX && metaKey && altKey | shiftKey) ||
    292          (!IS_OSX && ctrlKey && shiftKey)
    293        ) {
    294          this.cancelPick();
    295          this._walker.emit("picker-node-canceled");
    296        }
    297        return;
    298      }
    299      case event.DOM_VK_SHIFT:
    300        this._onHovered(this._lastMouseMoveEvent, true);
    301        return;
    302    }
    303 
    304    // Handle keys which require a currently picked node:
    305    // - LEFT_KEY: wider or parent
    306    // - RIGHT_KEY: narrower or child
    307    if (!this._currentNode) {
    308      return;
    309    }
    310 
    311    let currentNode = this._currentNode.node.rawNode;
    312    switch (event.keyCode) {
    313      // Wider.
    314      case event.DOM_VK_LEFT:
    315        if (!currentNode.parentElement) {
    316          return;
    317        }
    318        currentNode = currentNode.parentElement;
    319        break;
    320 
    321      // Narrower.
    322      case event.DOM_VK_RIGHT: {
    323        if (!currentNode.children.length) {
    324          return;
    325        }
    326 
    327        // Set firstElementChild by default
    328        let child = currentNode.firstElementChild;
    329        // If currentNode is parent of hoveredNode, then
    330        // previously selected childNode is set
    331        const hoveredNode = this._hoveredNode.rawNode;
    332        for (const sibling of currentNode.children) {
    333          if (sibling.contains(hoveredNode) || sibling === hoveredNode) {
    334            child = sibling;
    335          }
    336        }
    337 
    338        currentNode = child;
    339        break;
    340      }
    341 
    342      default:
    343        return;
    344    }
    345 
    346    // Store currently attached element
    347    this._currentNode = this._walker.attachElement(currentNode);
    348    this._walker.emit("picker-node-hovered", this._currentNode);
    349  }
    350 
    351  _onKeyUp(event) {
    352    if (event.keyCode === event.DOM_VK_SHIFT) {
    353      this._onHovered(this._lastMouseMoveEvent, false);
    354    }
    355    this._preventContentEvent(event);
    356  }
    357 
    358  _onSuppressedEvent(event) {
    359    if (event.type == "mousemove") {
    360      this._onHovered(event);
    361    } else if (event.type == "mouseup") {
    362      // Suppressed mousedown/mouseup events will be sent to us before they have
    363      // been converted into click events. Just treat any mouseup as a click.
    364      this._onPick(event);
    365    }
    366  }
    367 
    368  // In most cases, we need to prevent content events from reaching the content. This is
    369  // needed to avoid triggering actions such as submitting forms or following links.
    370  // In the case where the event happens on a remote frame however, we do want to let it
    371  // through. That is because otherwise the pickers started in nested remote frames will
    372  // never have a chance of picking their own elements.
    373  _preventContentEvent(event) {
    374    if (isRemoteBrowserElement(event.target)) {
    375      return;
    376    }
    377    event.stopPropagation();
    378    event.preventDefault();
    379  }
    380 
    381  /**
    382   * When the debugger pauses execution in a page, events will not be delivered
    383   * to any handlers added to elements on that page. This method uses the
    384   * document's setSuppressedEventListener interface to bypass this restriction:
    385   * events will be delivered to the callback at times when they would
    386   * otherwise be suppressed. The set of events delivered this way is currently
    387   * limited to mouse events.
    388   *
    389   * @param callback The function to call with suppressed events, or null.
    390   */
    391  _setSuppressedEventListener(callback) {
    392    if (!this._targetActor?.window?.document) {
    393      return;
    394    }
    395 
    396    // Pass the callback to setSuppressedEventListener as an EventListener.
    397    this._targetActor.window.document.setSuppressedEventListener(
    398      callback ? { handleEvent: callback } : null
    399    );
    400  }
    401 
    402  _startPickerListeners() {
    403    // All the following DOM Events will be cancelled to avoid reaching the web page.
    404    const cancelledEvents = [
    405      "dblclick",
    406      "mouseenter",
    407      "mousedown",
    408      "mouseover",
    409      "mouseup",
    410      "mouseout",
    411      "mouseleave",
    412    ];
    413    const eventsToSuppress = [
    414      { type: "click", handler: this._onPick },
    415      { type: "keydown", handler: this._onKeyDown },
    416      { type: "keyup", handler: this._onKeyUp },
    417      { type: "mousemove", handler: this._onHovered },
    418    ];
    419    for (const type of cancelledEvents) {
    420      eventsToSuppress.push({ type, handler: this._preventContentEvent });
    421    }
    422 
    423    const target = this._targetActor.chromeEventHandler;
    424    this.#eventListenersAbortController = new AbortController();
    425 
    426    for (const event of eventsToSuppress) {
    427      const { type, handler } = event;
    428 
    429      // When the node picker is enabled, DOM events should not be propagated or
    430      // trigger regular listeners.
    431      //
    432      // Event listeners can be added in two groups:
    433      // - the default group, used by all webcontent event listeners
    434      // - the mozSystemGroup group, which can be used by privileged JS
    435      //
    436      // For instance, the <video> widget controls rely on mozSystemGroup events
    437      // to handle clicks on their UI elements.
    438      //
    439      // In general we need to prevent events from both groups, as well as
    440      // handle a few events such as `click` to actually pick nodes.
    441      //
    442      // However events from the default group are resolved before the events
    443      // from the mozSystemGroup.
    444      // See https://searchfox.org/mozilla-central/rev/a85b25946f7f8eebf466bd7ad821b82b68a9231f/dom/events/EventDispatcher.cpp#652
    445      //
    446      // Therefore we need to make sure that we only "stop picking" in the
    447      // mozSystemGroup event listeners. When we stop picking, we will remove
    448      // the listeners added here, and if we do it too early, some unexpected
    449      // callbacks might still be triggered.
    450      //
    451      // For instance, if we were to stop picking in the default group "click"
    452      // event, then the mozSystemGroup "click" event would no longer be stopped
    453      // by our listeners, and some widget callbacks might be triggered, such as
    454      // <video> controls.
    455      //
    456      // As a summary: content listeners are resolved before mozSystemGroup
    457      // events, so we only prevent content listeners and handle the pick logic
    458      // at the latest point possible, in the mozSystemGroup listeners.
    459 
    460      // 1. Prevent content events.
    461      target.addEventListener(type, this._preventContentEvent, {
    462        capture: true,
    463        signal: this.#eventListenersAbortController.signal,
    464      });
    465 
    466      // 2. Prevent mozSystemGroup events and handle pick logic.
    467      target.addEventListener(type, handler, {
    468        capture: true,
    469        mozSystemGroup: true,
    470        signal: this.#eventListenersAbortController.signal,
    471      });
    472    }
    473 
    474    this._setSuppressedEventListener(this._onSuppressedEvent);
    475  }
    476 
    477  _stopPickerListeners() {
    478    this._setSuppressedEventListener(null);
    479 
    480    if (this.#eventListenersAbortController) {
    481      this.#eventListenersAbortController.abort();
    482      this.#eventListenersAbortController = null;
    483    }
    484  }
    485 
    486  _stopPicking() {
    487    this._stopPickerListeners();
    488    this._isPicking = false;
    489    this._hoveredNode = null;
    490    this._lastMouseMoveEvent = null;
    491    if (this.#remoteNodePickerNoticeHighlighter) {
    492      this.#remoteNodePickerNoticeHighlighter.hide();
    493    }
    494  }
    495 
    496  cancelPick() {
    497    if (this._targetActor.threadActor) {
    498      this._targetActor.threadActor.showOverlay();
    499    }
    500 
    501    if (this._isPicking) {
    502      this._stopPicking();
    503    }
    504  }
    505 
    506  pick(doFocus = false, isLocalTab = true) {
    507    if (this._targetActor.threadActor) {
    508      this._targetActor.threadActor.hideOverlay();
    509    }
    510 
    511    if (this._isPicking) {
    512      return;
    513    }
    514 
    515    this._startPickerListeners();
    516    this._isPicking = true;
    517 
    518    if (doFocus) {
    519      this._targetActor.window.focus();
    520    }
    521 
    522    if (!isLocalTab) {
    523      this.remoteNodePickerNoticeHighlighter.show();
    524    }
    525  }
    526 
    527  resetHoveredNodeReference() {
    528    this._hoveredNode = null;
    529  }
    530 
    531  destroy() {
    532    this.cancelPick();
    533 
    534    this._targetActor = null;
    535    this._walker = null;
    536    this.#remoteNodePickerNoticeHighlighter = null;
    537  }
    538 }
    539 
    540 exports.NodePicker = NodePicker;