tor-browser

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

EventTooltipHelper.js (14594B)


      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 { LocalizationHelper } = require("resource://devtools/shared/l10n.js");
      8 const L10N = new LocalizationHelper(
      9  "devtools/client/locales/inspector.properties"
     10 );
     11 
     12 const Editor = require("resource://devtools/client/shared/sourceeditor/editor.js");
     13 const beautify = require("resource://devtools/shared/jsbeautify/beautify.js");
     14 const EventEmitter = require("resource://devtools/shared/event-emitter.js");
     15 
     16 const XHTML_NS = "http://www.w3.org/1999/xhtml";
     17 const CONTAINER_WIDTH = 500;
     18 
     19 const L10N_USERDEFINED = L10N.getStr("eventsTooltip.userDefined");
     20 
     21 const L10N_BUBBLING = L10N.getStr("eventsTooltip.Bubbling");
     22 const L10N_CAPTURING = L10N.getStr("eventsTooltip.Capturing");
     23 
     24 class EventTooltip extends EventEmitter {
     25  /**
     26   * Set the content of a provided HTMLTooltip instance to display a list of event
     27   * listeners, with their event type, capturing argument and a link to the code
     28   * of the event handler.
     29   *
     30   * @param {HTMLTooltip} tooltip
     31   *        The tooltip instance on which the event details content should be set
     32   * @param {Array} eventListenerInfos
     33   *        A list of event listeners
     34   * @param {Toolbox} toolbox
     35   *        Toolbox used to select debugger panel
     36   * @param {NodeFront} nodeFront
     37   *        The nodeFront we're displaying event listeners for.
     38   */
     39  constructor(tooltip, eventListenerInfos, toolbox, nodeFront) {
     40    super();
     41 
     42    this._tooltip = tooltip;
     43    this._toolbox = toolbox;
     44    this._eventEditors = new WeakMap();
     45    this._nodeFront = nodeFront;
     46    this._eventListenersAbortController = new AbortController();
     47 
     48    // Used in tests: add a reference to the EventTooltip instance on the HTMLTooltip.
     49    this._tooltip.eventTooltip = this;
     50 
     51    this._headerClicked = this._headerClicked.bind(this);
     52    this._eventToggleCheckboxChanged =
     53      this._eventToggleCheckboxChanged.bind(this);
     54 
     55    this._subscriptions = [];
     56 
     57    const config = {
     58      mode: Editor.modes.javascript,
     59      lineNumbers: false,
     60      lineWrapping: true,
     61      readOnly: true,
     62      styleActiveLine: true,
     63      extraKeys: {},
     64      theme: "mozilla markup-view",
     65      cm6: true,
     66    };
     67 
     68    const doc = this._tooltip.doc;
     69    this.container = doc.createElementNS(XHTML_NS, "ul");
     70    this.container.className = "devtools-tooltip-events-container";
     71 
     72    const sourceMapURLService = this._toolbox.sourceMapURLService;
     73 
     74    for (let i = 0; i < eventListenerInfos.length; i++) {
     75      const listener = eventListenerInfos[i];
     76 
     77      // Create this early so we can refer to it from a closure, below.
     78      const content = doc.createElementNS(XHTML_NS, "div");
     79      const codeMirrorContainerId = `cm-${i}`;
     80      content.id = codeMirrorContainerId;
     81 
     82      // Header
     83      const header = doc.createElementNS(XHTML_NS, "div");
     84      header.className = "event-header";
     85      header.setAttribute("data-event-type", listener.type);
     86 
     87      const arrow = doc.createElementNS(XHTML_NS, "button");
     88      arrow.className = "theme-twisty";
     89      arrow.setAttribute("aria-expanded", "false");
     90      arrow.setAttribute("aria-owns", codeMirrorContainerId);
     91      arrow.setAttribute(
     92        "title",
     93        L10N.getFormatStr("eventsTooltip.toggleButton.label", listener.type)
     94      );
     95 
     96      header.appendChild(arrow);
     97 
     98      if (!listener.hide.type) {
     99        const eventTypeLabel = doc.createElementNS(XHTML_NS, "span");
    100        eventTypeLabel.className = "event-tooltip-event-type";
    101        eventTypeLabel.textContent = listener.type;
    102        eventTypeLabel.setAttribute("title", listener.type);
    103        header.appendChild(eventTypeLabel);
    104      }
    105 
    106      const filename = doc.createElementNS(XHTML_NS, "span");
    107      filename.className = "event-tooltip-filename devtools-monospace";
    108 
    109      let location = null;
    110      let text = listener.origin;
    111      let title = text;
    112      if (listener.hide.filename) {
    113        text = L10N.getStr("eventsTooltip.unknownLocation");
    114        title = L10N.getStr("eventsTooltip.unknownLocationExplanation");
    115      } else {
    116        location = this._parseLocation(listener.origin);
    117 
    118        // There will be no source actor if the listener is a native function
    119        // or wasn't a debuggee, in which case there's also not going to be
    120        // a sourcemap, so we don't need to worry about subscribing.
    121        if (location && listener.sourceActor) {
    122          location.id = listener.sourceActor;
    123 
    124          this._subscriptions.push(
    125            sourceMapURLService.subscribeByID(
    126              location.id,
    127              location.line,
    128              location.column,
    129              originalLocation => {
    130                const currentLoc = originalLocation || location;
    131 
    132                const newURI = currentLoc.url + ":" + currentLoc.line;
    133                filename.textContent = newURI;
    134                filename.setAttribute("title", newURI);
    135 
    136                // This is emitted for testing.
    137                this._tooltip.emitForTests("event-tooltip-source-map-ready");
    138              }
    139            )
    140          );
    141        }
    142      }
    143 
    144      filename.textContent = text;
    145      filename.setAttribute("title", title);
    146      header.appendChild(filename);
    147 
    148      if (!listener.hide.debugger) {
    149        const debuggerIcon = doc.createElementNS(XHTML_NS, "button");
    150        debuggerIcon.className = "event-tooltip-debugger-icon";
    151        const openInDebugger = L10N.getFormatStr(
    152          "eventsTooltip.openInDebugger2",
    153          listener.type
    154        );
    155        debuggerIcon.setAttribute("title", openInDebugger);
    156        header.appendChild(debuggerIcon);
    157      }
    158 
    159      const attributesContainer = doc.createElementNS(XHTML_NS, "div");
    160      attributesContainer.className = "event-tooltip-attributes-container";
    161      header.appendChild(attributesContainer);
    162 
    163      // Tags are used to refer to JS Frameworks like jQuery and React
    164      if (listener.tags) {
    165        for (const tag of listener.tags.split(",")) {
    166          const attributesBox = doc.createElementNS(XHTML_NS, "div");
    167          attributesBox.className = "event-tooltip-attributes-box";
    168          attributesContainer.appendChild(attributesBox);
    169 
    170          const tagBox = doc.createElementNS(XHTML_NS, "span");
    171          tagBox.className = "event-tooltip-attributes";
    172          tagBox.textContent = tag;
    173          tagBox.setAttribute("title", tag);
    174          attributesBox.appendChild(tagBox);
    175        }
    176        // Only show User-defined when we aren't using a framework,
    177        // which may use onClick but still be a browser supported event
    178      } else if (listener.isUserDefined) {
    179        const attributesBox = doc.createElementNS(XHTML_NS, "div");
    180        attributesBox.className = "event-tooltip-attributes-box";
    181        attributesContainer.appendChild(attributesBox);
    182 
    183        const capturing = doc.createElementNS(XHTML_NS, "span");
    184        capturing.className = "event-tooltip-attributes";
    185 
    186        capturing.textContent = L10N_USERDEFINED;
    187        attributesBox.appendChild(capturing);
    188      }
    189 
    190      if (!listener.hide.capturing) {
    191        const attributesBox = doc.createElementNS(XHTML_NS, "div");
    192        attributesBox.className = "event-tooltip-attributes-box";
    193        attributesContainer.appendChild(attributesBox);
    194 
    195        const capturing = doc.createElementNS(XHTML_NS, "span");
    196        capturing.className = "event-tooltip-attributes";
    197 
    198        const phase = listener.capturing ? L10N_CAPTURING : L10N_BUBBLING;
    199        capturing.textContent = phase;
    200        capturing.setAttribute("title", phase);
    201        attributesBox.appendChild(capturing);
    202      }
    203 
    204      const toggleListenerCheckbox = doc.createElementNS(XHTML_NS, "input");
    205      toggleListenerCheckbox.type = "checkbox";
    206      toggleListenerCheckbox.className =
    207        "event-tooltip-listener-toggle-checkbox";
    208      toggleListenerCheckbox.setAttribute(
    209        "aria-label",
    210        L10N.getFormatStr("eventsTooltip.toggleListenerLabel", listener.type)
    211      );
    212      if (listener.eventListenerInfoId) {
    213        toggleListenerCheckbox.checked = listener.enabled;
    214        toggleListenerCheckbox.setAttribute(
    215          "data-event-listener-info-id",
    216          listener.eventListenerInfoId
    217        );
    218        toggleListenerCheckbox.addEventListener(
    219          "change",
    220          this._eventToggleCheckboxChanged,
    221          { signal: this._eventListenersAbortController.signal }
    222        );
    223      } else {
    224        toggleListenerCheckbox.checked = true;
    225        toggleListenerCheckbox.setAttribute("disabled", true);
    226      }
    227      header.appendChild(toggleListenerCheckbox);
    228 
    229      // Content
    230      const editor = new Editor(config);
    231      this._eventEditors.set(content, {
    232        editor,
    233        handler: listener.handler,
    234        native: listener.native,
    235        appended: false,
    236        location,
    237      });
    238 
    239      content.className = "event-tooltip-content-box";
    240 
    241      const li = doc.createElementNS(XHTML_NS, "li");
    242      li.append(header, content);
    243      this.container.appendChild(li);
    244      this._addContentListeners(header);
    245    }
    246 
    247    this._tooltip.panel.innerHTML = "";
    248    this._tooltip.panel.appendChild(this.container);
    249    this._tooltip.setContentSize({ width: CONTAINER_WIDTH, height: Infinity });
    250  }
    251 
    252  _addContentListeners(header) {
    253    header.addEventListener("click", this._headerClicked, {
    254      signal: this._eventListenersAbortController.signal,
    255    });
    256  }
    257 
    258  _headerClicked(event) {
    259    // Clicking on the checkbox shouldn't impact the header (checkbox state change is
    260    // handled in _eventToggleCheckboxChanged).
    261    if (
    262      event.target.classList.contains("event-tooltip-listener-toggle-checkbox")
    263    ) {
    264      event.stopPropagation();
    265      return;
    266    }
    267 
    268    if (event.target.classList.contains("event-tooltip-debugger-icon")) {
    269      this._debugClicked(event);
    270      event.stopPropagation();
    271      return;
    272    }
    273 
    274    const doc = this._tooltip.doc;
    275    const header = event.currentTarget;
    276    const content = header.nextElementSibling;
    277    const twisty = header.querySelector(".theme-twisty");
    278 
    279    if (content.hasAttribute("open")) {
    280      header.classList.remove("content-expanded");
    281      twisty.setAttribute("aria-expanded", false);
    282      content.removeAttribute("open");
    283    } else {
    284      // Close other open events first
    285      const openHeaders = doc.querySelectorAll(
    286        ".event-header.content-expanded"
    287      );
    288      const openContent = doc.querySelectorAll(
    289        ".event-tooltip-content-box[open]"
    290      );
    291      for (const node of openHeaders) {
    292        node.classList.remove("content-expanded");
    293        const nodeTwisty = node.querySelector(".theme-twisty");
    294        nodeTwisty.setAttribute("aria-expanded", false);
    295      }
    296      for (const node of openContent) {
    297        node.removeAttribute("open");
    298      }
    299 
    300      header.classList.add("content-expanded");
    301      content.setAttribute("open", "");
    302      twisty.setAttribute("aria-expanded", true);
    303 
    304      const eventEditor = this._eventEditors.get(content);
    305 
    306      if (eventEditor.appended) {
    307        return;
    308      }
    309 
    310      const { editor, handler } = eventEditor;
    311 
    312      const iframe = doc.createElementNS(XHTML_NS, "iframe");
    313      iframe.classList.add("event-tooltip-editor-frame");
    314      iframe.setAttribute(
    315        "title",
    316        L10N.getFormatStr(
    317          "eventsTooltip.codeIframeTitle",
    318          header.getAttribute("data-event-type")
    319        )
    320      );
    321 
    322      editor.appendTo(content, iframe).then(() => {
    323        const tidied = beautify.js(handler, { indent_size: 2 });
    324        editor.setText(tidied);
    325 
    326        eventEditor.appended = true;
    327 
    328        const container = header.parentElement.getBoundingClientRect();
    329        if (header.getBoundingClientRect().top < container.top) {
    330          header.scrollIntoView(true);
    331        } else if (content.getBoundingClientRect().bottom > container.bottom) {
    332          content.scrollIntoView(false);
    333        }
    334 
    335        this._tooltip.emitForTests("event-tooltip-ready");
    336      });
    337    }
    338  }
    339 
    340  _debugClicked(event) {
    341    const header = event.currentTarget;
    342    const content = header.nextElementSibling;
    343 
    344    const { location } = this._eventEditors.get(content);
    345    if (location) {
    346      // Save a copy of toolbox as it will be set to null when we hide the tooltip.
    347      const toolbox = this._toolbox;
    348 
    349      this._tooltip.hide();
    350 
    351      toolbox.viewSourceInDebugger(
    352        location.url,
    353        location.line,
    354        location.column,
    355        location.id
    356      );
    357    }
    358  }
    359 
    360  async _eventToggleCheckboxChanged(event) {
    361    const checkbox = event.currentTarget;
    362    const id = checkbox.getAttribute("data-event-listener-info-id");
    363    if (checkbox.checked) {
    364      await this._nodeFront.enableEventListener(id);
    365    } else {
    366      await this._nodeFront.disableEventListener(id);
    367    }
    368    this.emit("event-tooltip-listener-toggled", {
    369      hasDisabledEventListeners:
    370        // No need to query the other checkboxes if the handled checkbox is unchecked
    371        !checkbox.checked ||
    372        this._tooltip.doc.querySelector(
    373          `input.event-tooltip-listener-toggle-checkbox:not(:checked)`
    374        ) !== null,
    375    });
    376  }
    377 
    378  /**
    379   * Parse URI and return {url, line, column}; or return null if it can't be parsed.
    380   */
    381  _parseLocation(uri) {
    382    if (uri && uri !== "?") {
    383      uri = uri.replace(/"/g, "");
    384 
    385      let matches = uri.match(/(.*):(\d+):(\d+$)/);
    386 
    387      if (matches) {
    388        return {
    389          url: matches[1],
    390          line: parseInt(matches[2], 10),
    391          column: parseInt(matches[3], 10),
    392        };
    393      } else if ((matches = uri.match(/(.*):(\d+$)/))) {
    394        return {
    395          url: matches[1],
    396          line: parseInt(matches[2], 10),
    397          column: null,
    398        };
    399      }
    400      return { url: uri, line: 1, column: null };
    401    }
    402    return null;
    403  }
    404 
    405  destroy() {
    406    if (this._tooltip) {
    407      const boxes = this.container.querySelectorAll(
    408        ".event-tooltip-content-box"
    409      );
    410 
    411      for (const box of boxes) {
    412        const { editor } = this._eventEditors.get(box);
    413        editor.destroy();
    414      }
    415 
    416      this._eventEditors = null;
    417      this._tooltip.eventTooltip = null;
    418    }
    419 
    420    this.clearEvents();
    421    if (this._eventListenersAbortController) {
    422      this._eventListenersAbortController.abort();
    423      this._eventListenersAbortController = null;
    424    }
    425 
    426    for (const unsubscribe of this._subscriptions) {
    427      unsubscribe();
    428    }
    429 
    430    this._toolbox = this._tooltip = this._nodeFront = null;
    431  }
    432 }
    433 
    434 module.exports.EventTooltip = EventTooltip;