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;