419 lines
14 KiB
JavaScript
419 lines
14 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("resource://devtools/shared/l10n.js");
|
|
const L10N = new LocalizationHelper(
|
|
"devtools/client/locales/inspector.properties"
|
|
);
|
|
|
|
const Editor = require("resource://devtools/client/shared/sourceeditor/editor.js");
|
|
const beautify = require("resource://devtools/shared/jsbeautify/beautify.js");
|
|
const EventEmitter = require("resource://devtools/shared/event-emitter.js");
|
|
|
|
const XHTML_NS = "http://www.w3.org/1999/xhtml";
|
|
const CONTAINER_WIDTH = 500;
|
|
|
|
const L10N_BUBBLING = L10N.getStr("eventsTooltip.Bubbling");
|
|
const L10N_CAPTURING = L10N.getStr("eventsTooltip.Capturing");
|
|
|
|
class EventTooltip extends EventEmitter {
|
|
/**
|
|
* 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) {
|
|
super();
|
|
|
|
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",
|
|
cm6: true,
|
|
};
|
|
|
|
const doc = this._tooltip.doc;
|
|
this.container = doc.createElementNS(XHTML_NS, "ul");
|
|
this.container.className = "devtools-tooltip-events-container";
|
|
|
|
const sourceMapURLService = this._toolbox.sourceMapURLService;
|
|
|
|
for (let i = 0; i < eventListenerInfos.length; i++) {
|
|
const listener = eventListenerInfos[i];
|
|
|
|
// Create this early so we can refer to it from a closure, below.
|
|
const content = doc.createElementNS(XHTML_NS, "div");
|
|
const codeMirrorContainerId = `cm-${i}`;
|
|
content.id = codeMirrorContainerId;
|
|
|
|
// Header
|
|
const header = doc.createElementNS(XHTML_NS, "div");
|
|
header.className = "event-header";
|
|
header.setAttribute("data-event-type", listener.type);
|
|
|
|
const arrow = doc.createElementNS(XHTML_NS, "button");
|
|
arrow.className = "theme-twisty";
|
|
arrow.setAttribute("aria-expanded", "false");
|
|
arrow.setAttribute("aria-owns", codeMirrorContainerId);
|
|
arrow.setAttribute(
|
|
"title",
|
|
L10N.getFormatStr("eventsTooltip.toggleButton.label", listener.type)
|
|
);
|
|
|
|
header.appendChild(arrow);
|
|
|
|
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, "button");
|
|
debuggerIcon.className = "event-tooltip-debugger-icon";
|
|
const openInDebugger = L10N.getFormatStr(
|
|
"eventsTooltip.openInDebugger2",
|
|
listener.type
|
|
);
|
|
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 ? L10N_CAPTURING : L10N_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";
|
|
toggleListenerCheckbox.setAttribute(
|
|
"aria-label",
|
|
L10N.getFormatStr("eventsTooltip.toggleListenerLabel", listener.type)
|
|
);
|
|
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,
|
|
handler: listener.handler,
|
|
native: listener.native,
|
|
appended: false,
|
|
location,
|
|
});
|
|
|
|
content.className = "event-tooltip-content-box";
|
|
|
|
const li = doc.createElementNS(XHTML_NS, "li");
|
|
li.append(header, content);
|
|
this.container.appendChild(li);
|
|
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;
|
|
const twisty = header.querySelector(".theme-twisty");
|
|
|
|
if (content.hasAttribute("open")) {
|
|
header.classList.remove("content-expanded");
|
|
twisty.setAttribute("aria-expanded", false);
|
|
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");
|
|
const nodeTwisty = node.querySelector(".theme-twisty");
|
|
nodeTwisty.setAttribute("aria-expanded", false);
|
|
}
|
|
for (const node of openContent) {
|
|
node.removeAttribute("open");
|
|
}
|
|
|
|
header.classList.add("content-expanded");
|
|
content.setAttribute("open", "");
|
|
twisty.setAttribute("aria-expanded", true);
|
|
|
|
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");
|
|
iframe.setAttribute(
|
|
"title",
|
|
L10N.getFormatStr(
|
|
"eventsTooltip.codeIframeTitle",
|
|
header.getAttribute("data-event-type")
|
|
)
|
|
);
|
|
|
|
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.emit("event-tooltip-listener-toggled", {
|
|
hasDisabledEventListeners:
|
|
// No need to query the other checkboxes if the handled checkbox is unchecked
|
|
!checkbox.checked ||
|
|
this._tooltip.doc.querySelector(
|
|
`input.event-tooltip-listener-toggle-checkbox:not(:checked)`
|
|
) !== null,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
this.clearEvents();
|
|
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;
|