fune/devtools/client/shared/widgets/tooltip/EventTooltipHelper.js
Nicolas Chevobbe a232d2448c Bug 1492497 - [devtools] Add a way to disable (and re-enable) event listener for a given node. r=ochameau,devtools-backward-compat-reviewers,bomsy.
This patch adds a checkbox at the end of each event listeners in the EventTooltip,
which allow the user to disable/re-enable a given event listener.

This is done by managing a Map of nsIEventListenerInfo object in the NodeActor,
which we populate from `getEventListenerInfo`. Each `nsIEventListenerInfo` is
assigned a generated id, which can then be used to call the new NodeActor
methods, `(enable|disable)EventListener`.

We don't support disabling jquery/React event listeners at the moment, so we
display the checkbox for them as well, but disabled.

Differential Revision: https://phabricator.services.mozilla.com/D135133
2022-01-12 12:42:48 +00:00

374 lines
12 KiB
JavaScript

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
const { LocalizationHelper } = require("devtools/shared/l10n");
const L10N = new LocalizationHelper(
"devtools/client/locales/inspector.properties"
);
const Editor = require("devtools/client/shared/sourceeditor/editor");
const beautify = require("devtools/shared/jsbeautify/beautify");
const XHTML_NS = "http://www.w3.org/1999/xhtml";
const CONTAINER_WIDTH = 500;
class EventTooltip {
/**
* Set the content of a provided HTMLTooltip instance to display a list of event
* listeners, with their event type, capturing argument and a link to the code
* of the event handler.
*
* @param {HTMLTooltip} tooltip
* The tooltip instance on which the event details content should be set
* @param {Array} eventListenerInfos
* A list of event listeners
* @param {Toolbox} toolbox
* Toolbox used to select debugger panel
* @param {NodeFront} nodeFront
* The nodeFront we're displaying event listeners for.
*/
constructor(tooltip, eventListenerInfos, toolbox, nodeFront) {
this._tooltip = tooltip;
this._toolbox = toolbox;
this._eventEditors = new WeakMap();
this._nodeFront = nodeFront;
this._eventListenersAbortController = new AbortController();
// Used in tests: add a reference to the EventTooltip instance on the HTMLTooltip.
this._tooltip.eventTooltip = this;
this._headerClicked = this._headerClicked.bind(this);
this._eventToggleCheckboxChanged = this._eventToggleCheckboxChanged.bind(
this
);
this._subscriptions = [];
const config = {
mode: Editor.modes.js,
lineNumbers: false,
lineWrapping: true,
readOnly: true,
styleActiveLine: true,
extraKeys: {},
theme: "mozilla markup-view",
};
const doc = this._tooltip.doc;
this.container = doc.createElementNS(XHTML_NS, "div");
this.container.className = "devtools-tooltip-events-container";
const sourceMapURLService = this._toolbox.sourceMapURLService;
const Bubbling = L10N.getStr("eventsTooltip.Bubbling");
const Capturing = L10N.getStr("eventsTooltip.Capturing");
for (const listener of eventListenerInfos) {
// Create this early so we can refer to it from a closure, below.
const content = doc.createElementNS(XHTML_NS, "div");
// Header
const header = doc.createElementNS(XHTML_NS, "div");
header.className = "event-header";
const arrow = doc.createElementNS(XHTML_NS, "span");
arrow.className = "theme-twisty";
header.appendChild(arrow);
this.container.appendChild(header);
if (!listener.hide.type) {
const eventTypeLabel = doc.createElementNS(XHTML_NS, "span");
eventTypeLabel.className = "event-tooltip-event-type";
eventTypeLabel.textContent = listener.type;
eventTypeLabel.setAttribute("title", listener.type);
header.appendChild(eventTypeLabel);
}
const filename = doc.createElementNS(XHTML_NS, "span");
filename.className = "event-tooltip-filename devtools-monospace";
let location = null;
let text = listener.origin;
let title = text;
if (listener.hide.filename) {
text = L10N.getStr("eventsTooltip.unknownLocation");
title = L10N.getStr("eventsTooltip.unknownLocationExplanation");
} else {
location = this._parseLocation(listener.origin);
// There will be no source actor if the listener is a native function
// or wasn't a debuggee, in which case there's also not going to be
// a sourcemap, so we don't need to worry about subscribing.
if (location && listener.sourceActor) {
location.id = listener.sourceActor;
this._subscriptions.push(
sourceMapURLService.subscribeByID(
location.id,
location.line,
location.column,
originalLocation => {
const currentLoc = originalLocation || location;
const newURI = currentLoc.url + ":" + currentLoc.line;
filename.textContent = newURI;
filename.setAttribute("title", newURI);
// This is emitted for testing.
this._tooltip.emitForTests("event-tooltip-source-map-ready");
}
)
);
}
}
filename.textContent = text;
filename.setAttribute("title", title);
header.appendChild(filename);
if (!listener.hide.debugger) {
const debuggerIcon = doc.createElementNS(XHTML_NS, "div");
debuggerIcon.className = "event-tooltip-debugger-icon";
const openInDebugger = L10N.getStr("eventsTooltip.openInDebugger");
debuggerIcon.setAttribute("title", openInDebugger);
header.appendChild(debuggerIcon);
}
const attributesContainer = doc.createElementNS(XHTML_NS, "div");
attributesContainer.className = "event-tooltip-attributes-container";
header.appendChild(attributesContainer);
if (listener.tags) {
for (const tag of listener.tags.split(",")) {
const attributesBox = doc.createElementNS(XHTML_NS, "div");
attributesBox.className = "event-tooltip-attributes-box";
attributesContainer.appendChild(attributesBox);
const tagBox = doc.createElementNS(XHTML_NS, "span");
tagBox.className = "event-tooltip-attributes";
tagBox.textContent = tag;
tagBox.setAttribute("title", tag);
attributesBox.appendChild(tagBox);
}
}
if (!listener.hide.capturing) {
const attributesBox = doc.createElementNS(XHTML_NS, "div");
attributesBox.className = "event-tooltip-attributes-box";
attributesContainer.appendChild(attributesBox);
const capturing = doc.createElementNS(XHTML_NS, "span");
capturing.className = "event-tooltip-attributes";
const phase = listener.capturing ? Capturing : Bubbling;
capturing.textContent = phase;
capturing.setAttribute("title", phase);
attributesBox.appendChild(capturing);
}
const toggleListenerCheckbox = doc.createElementNS(XHTML_NS, "input");
toggleListenerCheckbox.type = "checkbox";
toggleListenerCheckbox.className =
"event-tooltip-listener-toggle-checkbox";
if (listener.eventListenerInfoId) {
toggleListenerCheckbox.checked = listener.enabled;
toggleListenerCheckbox.setAttribute(
"data-event-listener-info-id",
listener.eventListenerInfoId
);
toggleListenerCheckbox.addEventListener(
"change",
this._eventToggleCheckboxChanged,
{ signal: this._eventListenersAbortController.signal }
);
} else {
toggleListenerCheckbox.checked = true;
toggleListenerCheckbox.setAttribute("disabled", true);
}
header.appendChild(toggleListenerCheckbox);
// Content
const editor = new Editor(config);
this._eventEditors.set(content, {
editor: editor,
handler: listener.handler,
native: listener.native,
appended: false,
location,
});
content.className = "event-tooltip-content-box";
this.container.appendChild(content);
this._addContentListeners(header);
}
this._tooltip.panel.innerHTML = "";
this._tooltip.panel.appendChild(this.container);
this._tooltip.setContentSize({ width: CONTAINER_WIDTH, height: Infinity });
}
_addContentListeners(header) {
header.addEventListener("click", this._headerClicked, {
signal: this._eventListenersAbortController.signal,
});
}
_headerClicked(event) {
// Clicking on the checkbox shouldn't impact the header (checkbox state change is
// handled in _eventToggleCheckboxChanged).
if (
event.target.classList.contains("event-tooltip-listener-toggle-checkbox")
) {
event.stopPropagation();
return;
}
if (event.target.classList.contains("event-tooltip-debugger-icon")) {
this._debugClicked(event);
event.stopPropagation();
return;
}
const doc = this._tooltip.doc;
const header = event.currentTarget;
const content = header.nextElementSibling;
if (content.hasAttribute("open")) {
header.classList.remove("content-expanded");
content.removeAttribute("open");
} else {
// Close other open events first
const openHeaders = doc.querySelectorAll(
".event-header.content-expanded"
);
const openContent = doc.querySelectorAll(
".event-tooltip-content-box[open]"
);
for (const node of openHeaders) {
node.classList.remove("content-expanded");
}
for (const node of openContent) {
node.removeAttribute("open");
}
header.classList.add("content-expanded");
content.setAttribute("open", "");
const eventEditor = this._eventEditors.get(content);
if (eventEditor.appended) {
return;
}
const { editor, handler } = eventEditor;
const iframe = doc.createElementNS(XHTML_NS, "iframe");
iframe.classList.add("event-tooltip-editor-frame");
editor.appendTo(content, iframe).then(() => {
const tidied = beautify.js(handler, { indent_size: 2 });
editor.setText(tidied);
eventEditor.appended = true;
const container = header.parentElement.getBoundingClientRect();
if (header.getBoundingClientRect().top < container.top) {
header.scrollIntoView(true);
} else if (content.getBoundingClientRect().bottom > container.bottom) {
content.scrollIntoView(false);
}
this._tooltip.emitForTests("event-tooltip-ready");
});
}
}
_debugClicked(event) {
const header = event.currentTarget;
const content = header.nextElementSibling;
const { location } = this._eventEditors.get(content);
if (location) {
// Save a copy of toolbox as it will be set to null when we hide the tooltip.
const toolbox = this._toolbox;
this._tooltip.hide();
toolbox.viewSourceInDebugger(
location.url,
location.line,
location.column,
location.id
);
}
}
async _eventToggleCheckboxChanged(event) {
const checkbox = event.currentTarget;
const id = checkbox.getAttribute("data-event-listener-info-id");
if (checkbox.checked) {
await this._nodeFront.enableEventListener(id);
} else {
await this._nodeFront.disableEventListener(id);
}
this._tooltip.emitForTests("event-tooltip-listener-toggled");
}
/**
* Parse URI and return {url, line, column}; or return null if it can't be parsed.
*/
_parseLocation(uri) {
if (uri && uri !== "?") {
uri = uri.replace(/"/g, "");
let matches = uri.match(/(.*):(\d+):(\d+$)/);
if (matches) {
return {
url: matches[1],
line: parseInt(matches[2], 10),
column: parseInt(matches[3], 10),
};
} else if ((matches = uri.match(/(.*):(\d+$)/))) {
return {
url: matches[1],
line: parseInt(matches[2], 10),
column: null,
};
}
return { url: uri, line: 1, column: null };
}
return null;
}
destroy() {
if (this._tooltip) {
const boxes = this.container.querySelectorAll(
".event-tooltip-content-box"
);
for (const box of boxes) {
const { editor } = this._eventEditors.get(box);
editor.destroy();
}
this._eventEditors = null;
this._tooltip.eventTooltip = null;
}
if (this._eventListenersAbortController) {
this._eventListenersAbortController.abort();
this._eventListenersAbortController = null;
}
for (const unsubscribe of this._subscriptions) {
unsubscribe();
}
this._toolbox = this._tooltip = this._nodeFront = null;
}
}
module.exports.EventTooltip = EventTooltip;