summaryrefslogtreecommitdiffstats
path: root/devtools/client/shared/widgets/tooltip/EventTooltipHelper.js
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/shared/widgets/tooltip/EventTooltipHelper.js')
-rw-r--r--devtools/client/shared/widgets/tooltip/EventTooltipHelper.js419
1 files changed, 419 insertions, 0 deletions
diff --git a/devtools/client/shared/widgets/tooltip/EventTooltipHelper.js b/devtools/client/shared/widgets/tooltip/EventTooltipHelper.js
new file mode 100644
index 0000000000..01f1b0ec91
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/EventTooltipHelper.js
@@ -0,0 +1,419 @@
+/* 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;