node-picker.js (17090B)
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 loader.lazyRequireGetter( 8 this, 9 "isRemoteBrowserElement", 10 "resource://devtools/shared/layout/utils.js", 11 true 12 ); 13 loader.lazyRequireGetter( 14 this, 15 "HighlighterEnvironment", 16 "resource://devtools/server/actors/highlighters.js", 17 true 18 ); 19 loader.lazyRequireGetter( 20 this, 21 "RemoteNodePickerNotice", 22 "resource://devtools/server/actors/highlighters/remote-node-picker-notice.js", 23 true 24 ); 25 26 const IS_OSX = Services.appinfo.OS === "Darwin"; 27 28 class NodePicker { 29 #eventListenersAbortController; 30 #remoteNodePickerNoticeHighlighter; 31 32 constructor(walker, targetActor) { 33 this._walker = walker; 34 this._targetActor = targetActor; 35 36 this._isPicking = false; 37 this._hoveredNode = null; 38 this._currentNode = null; 39 40 this._onHovered = this._onHovered.bind(this); 41 this._onKeyDown = this._onKeyDown.bind(this); 42 this._onKeyUp = this._onKeyUp.bind(this); 43 this._onPick = this._onPick.bind(this); 44 this._onSuppressedEvent = this._onSuppressedEvent.bind(this); 45 this._preventContentEvent = this._preventContentEvent.bind(this); 46 } 47 48 get remoteNodePickerNoticeHighlighter() { 49 if (!this.#remoteNodePickerNoticeHighlighter) { 50 const env = new HighlighterEnvironment(); 51 env.initFromTargetActor(this._targetActor); 52 this.#remoteNodePickerNoticeHighlighter = new RemoteNodePickerNotice(env); 53 } 54 55 return this.#remoteNodePickerNoticeHighlighter; 56 } 57 58 /** 59 * Find the element from the passed mouse event. If shift isn't pressed (or shiftKey is false) 60 * this will ignore all elements who can't consume pointer events (e.g. with inert attribute 61 * or `pointer-events: none` style). 62 * 63 * @param {MouseEvent} event 64 * @param {boolean} shiftKey: If passed, will override event.shiftKey 65 * @returns {object} An object compatible with the disconnectedNode type. 66 */ 67 _findAndAttachElement(event, shiftKey = event.shiftKey) { 68 // originalTarget allows access to the "real" element before any retargeting 69 // is applied, such as in the case of XBL anonymous elements. See also 70 // https://developer.mozilla.org/docs/XBL/XBL_1.0_Reference/Anonymous_Content#Event_Flow_and_Targeting 71 let node = event.originalTarget || event.target; 72 73 // When holding the Shift key, search for the element at the mouse position (as opposed 74 // to the event target). This would make it possible to pick nodes for which we won't 75 // get events for (e.g. elements with `pointer-events: none`). 76 if (shiftKey) { 77 node = this._findNodeAtMouseEventPosition(event) || node; 78 } 79 80 return this._walker.attachElement(node); 81 } 82 83 /** 84 * Return the topmost visible element located at the event mouse position. This is 85 * different from retrieving the event target as it allows to retrieve elements for which 86 * we wouldn't have mouse event triggered (e.g. elements with `pointer-events: none`) 87 * 88 * @param {MouseEvent} event 89 * @returns HTMLElement 90 */ 91 _findNodeAtMouseEventPosition(event) { 92 const win = this._targetActor.window; 93 const winUtils = win.windowUtils; 94 const rectSize = 1; 95 const elements = Array.from( 96 winUtils.nodesFromRect( 97 // aX 98 event.clientX, 99 // aY 100 event.clientY, 101 // aTopSize 102 rectSize, 103 // aRightSize 104 rectSize, 105 // aBottomSize 106 rectSize, 107 // aLeftSize 108 rectSize, 109 // aIgnoreRootScrollFrame 110 true, 111 // aFlushLayout 112 false, 113 // aOnlyVisible 114 true, 115 // aTransparencyThreshold 116 1 117 ) 118 ).filter(element => { 119 // Strip out text nodes, we want to highlight Elements only 120 return !win.Text.isInstance(element); 121 }); 122 123 if (!elements.length) { 124 return null; 125 } 126 127 if (elements.length === 1) { 128 return elements[0]; 129 } 130 131 // Let's return the first element that we find that is not a parent of another matching 132 // element, so we get the "deepest" element possible. 133 // At this points, we have at least 2 elements and are guaranteed to find an element 134 // which is not the parent of any other ones. 135 return elements.find( 136 element => !elements.some(e => element !== e && element.contains(e)) 137 ); 138 } 139 140 /** 141 * Returns `true` if the event was dispatched from a window included in 142 * the current highlighter environment; or if the highlighter environment has 143 * chrome privileges 144 * 145 * @param {Event} event 146 * The event to allow 147 * @return {boolean} 148 */ 149 _isEventAllowed({ view }) { 150 // Allow "non multiprocess" browser toolbox to inspect documents loaded in the parent 151 // process (e.g. about:robots) 152 if (this._targetActor.window.isChromeWindow) { 153 return true; 154 } 155 156 return this._targetActor.windows.includes(view); 157 } 158 159 /** 160 * Returns true if the passed event original target is in the RemoteNodePickerNotice. 161 * 162 * @param {Event} event 163 * @returns {boolean} 164 */ 165 _isEventInRemoteNodePickerNotice(event) { 166 return ( 167 this.#remoteNodePickerNoticeHighlighter && 168 event.originalTarget?.closest?.( 169 `#${this.#remoteNodePickerNoticeHighlighter.rootElementId}` 170 ) 171 ); 172 } 173 174 /** 175 * Pick a node on click. 176 * 177 * This method doesn't respond anything interesting, however, it starts 178 * mousemove, and click listeners on the content document to fire 179 * events and let connected clients know when nodes are hovered over or 180 * clicked. 181 * 182 * Once a node is picked, events will cease, and listeners will be removed. 183 */ 184 _onPick(event) { 185 // If the picked node is a remote frame, then we need to let the event through 186 // since there's a highlighter actor in that sub-frame also picking. 187 if (isRemoteBrowserElement(event.target)) { 188 return; 189 } 190 191 this._preventContentEvent(event); 192 if (!this._isEventAllowed(event)) { 193 return; 194 } 195 196 // If the click was done inside the node picker notice highlighter (e.g. clicking the 197 // close button), directly call its `onClick` method, as it doesn't have event listeners 198 // itself, to avoid managing events (+ suppressedEventListeners) for the same target 199 // from different places. 200 if (this._isEventInRemoteNodePickerNotice(event)) { 201 this.#remoteNodePickerNoticeHighlighter.onClick(event); 202 return; 203 } 204 205 // If Ctrl (Or Cmd on OSX) is pressed, this is only a preview click. 206 // Send the event to the client, but don't stop picking. 207 if ((IS_OSX && event.metaKey) || (!IS_OSX && event.ctrlKey)) { 208 this._walker.emit( 209 "picker-node-previewed", 210 this._findAndAttachElement(event) 211 ); 212 return; 213 } 214 215 this._stopPicking(); 216 217 if (!this._currentNode) { 218 this._currentNode = this._findAndAttachElement(event); 219 } 220 221 this._walker.emit("picker-node-picked", this._currentNode); 222 } 223 224 /** 225 * mousemove event handler 226 * 227 * @param {MouseEvent} event 228 * @param {boolean} shiftKeyOverride: If passed, will override event.shiftKey in _findAndAttachElement 229 */ 230 _onHovered(event, shiftKeyOverride) { 231 // If the hovered node is a remote frame, then we need to let the event through 232 // since there's a highlighter actor in that sub-frame also picking. 233 if (isRemoteBrowserElement(event.target)) { 234 return; 235 } 236 237 this._preventContentEvent(event); 238 if (!this._isEventAllowed(event)) { 239 return; 240 } 241 242 this._lastMouseMoveEvent = event; 243 244 // Always call remoteNodePickerNotice handleHoveredElement so the hover state can be updated 245 // (it doesn't have its own event listeners to avoid managing events and suppressed 246 // events for the same target from different places). 247 if (this.#remoteNodePickerNoticeHighlighter) { 248 this.#remoteNodePickerNoticeHighlighter.handleHoveredElement(event); 249 if (this._isEventInRemoteNodePickerNotice(event)) { 250 return; 251 } 252 } 253 254 this._currentNode = this._findAndAttachElement(event, shiftKeyOverride); 255 if (this._hoveredNode !== this._currentNode.node) { 256 this._walker.emit("picker-node-hovered", this._currentNode); 257 this._hoveredNode = this._currentNode.node; 258 } 259 } 260 261 // eslint-disable-next-line complexity 262 _onKeyDown(event) { 263 if (!this._isPicking) { 264 return; 265 } 266 267 this._preventContentEvent(event); 268 if (!this._isEventAllowed(event)) { 269 return; 270 } 271 272 // Handle keys which don't require a currently picked node: 273 // - ENTER/CARRIAGE_RETURN: Picks currentNode 274 // - ESC/CTRL+SHIFT+C: Cancels picker, picks currentNode 275 // - SHIFT: Trigger onHover, handling `pointer-events: none` nodes 276 switch (event.keyCode) { 277 // Select the element. 278 case event.DOM_VK_RETURN: 279 this._onPick(event); 280 return; 281 282 // Cancel pick mode. 283 case event.DOM_VK_ESCAPE: 284 this.cancelPick(); 285 this._walker.emit("picker-node-canceled"); 286 return; 287 case event.DOM_VK_C: { 288 const { altKey, ctrlKey, metaKey, shiftKey } = event; 289 290 if ( 291 (IS_OSX && metaKey && altKey | shiftKey) || 292 (!IS_OSX && ctrlKey && shiftKey) 293 ) { 294 this.cancelPick(); 295 this._walker.emit("picker-node-canceled"); 296 } 297 return; 298 } 299 case event.DOM_VK_SHIFT: 300 this._onHovered(this._lastMouseMoveEvent, true); 301 return; 302 } 303 304 // Handle keys which require a currently picked node: 305 // - LEFT_KEY: wider or parent 306 // - RIGHT_KEY: narrower or child 307 if (!this._currentNode) { 308 return; 309 } 310 311 let currentNode = this._currentNode.node.rawNode; 312 switch (event.keyCode) { 313 // Wider. 314 case event.DOM_VK_LEFT: 315 if (!currentNode.parentElement) { 316 return; 317 } 318 currentNode = currentNode.parentElement; 319 break; 320 321 // Narrower. 322 case event.DOM_VK_RIGHT: { 323 if (!currentNode.children.length) { 324 return; 325 } 326 327 // Set firstElementChild by default 328 let child = currentNode.firstElementChild; 329 // If currentNode is parent of hoveredNode, then 330 // previously selected childNode is set 331 const hoveredNode = this._hoveredNode.rawNode; 332 for (const sibling of currentNode.children) { 333 if (sibling.contains(hoveredNode) || sibling === hoveredNode) { 334 child = sibling; 335 } 336 } 337 338 currentNode = child; 339 break; 340 } 341 342 default: 343 return; 344 } 345 346 // Store currently attached element 347 this._currentNode = this._walker.attachElement(currentNode); 348 this._walker.emit("picker-node-hovered", this._currentNode); 349 } 350 351 _onKeyUp(event) { 352 if (event.keyCode === event.DOM_VK_SHIFT) { 353 this._onHovered(this._lastMouseMoveEvent, false); 354 } 355 this._preventContentEvent(event); 356 } 357 358 _onSuppressedEvent(event) { 359 if (event.type == "mousemove") { 360 this._onHovered(event); 361 } else if (event.type == "mouseup") { 362 // Suppressed mousedown/mouseup events will be sent to us before they have 363 // been converted into click events. Just treat any mouseup as a click. 364 this._onPick(event); 365 } 366 } 367 368 // In most cases, we need to prevent content events from reaching the content. This is 369 // needed to avoid triggering actions such as submitting forms or following links. 370 // In the case where the event happens on a remote frame however, we do want to let it 371 // through. That is because otherwise the pickers started in nested remote frames will 372 // never have a chance of picking their own elements. 373 _preventContentEvent(event) { 374 if (isRemoteBrowserElement(event.target)) { 375 return; 376 } 377 event.stopPropagation(); 378 event.preventDefault(); 379 } 380 381 /** 382 * When the debugger pauses execution in a page, events will not be delivered 383 * to any handlers added to elements on that page. This method uses the 384 * document's setSuppressedEventListener interface to bypass this restriction: 385 * events will be delivered to the callback at times when they would 386 * otherwise be suppressed. The set of events delivered this way is currently 387 * limited to mouse events. 388 * 389 * @param callback The function to call with suppressed events, or null. 390 */ 391 _setSuppressedEventListener(callback) { 392 if (!this._targetActor?.window?.document) { 393 return; 394 } 395 396 // Pass the callback to setSuppressedEventListener as an EventListener. 397 this._targetActor.window.document.setSuppressedEventListener( 398 callback ? { handleEvent: callback } : null 399 ); 400 } 401 402 _startPickerListeners() { 403 // All the following DOM Events will be cancelled to avoid reaching the web page. 404 const cancelledEvents = [ 405 "dblclick", 406 "mouseenter", 407 "mousedown", 408 "mouseover", 409 "mouseup", 410 "mouseout", 411 "mouseleave", 412 ]; 413 const eventsToSuppress = [ 414 { type: "click", handler: this._onPick }, 415 { type: "keydown", handler: this._onKeyDown }, 416 { type: "keyup", handler: this._onKeyUp }, 417 { type: "mousemove", handler: this._onHovered }, 418 ]; 419 for (const type of cancelledEvents) { 420 eventsToSuppress.push({ type, handler: this._preventContentEvent }); 421 } 422 423 const target = this._targetActor.chromeEventHandler; 424 this.#eventListenersAbortController = new AbortController(); 425 426 for (const event of eventsToSuppress) { 427 const { type, handler } = event; 428 429 // When the node picker is enabled, DOM events should not be propagated or 430 // trigger regular listeners. 431 // 432 // Event listeners can be added in two groups: 433 // - the default group, used by all webcontent event listeners 434 // - the mozSystemGroup group, which can be used by privileged JS 435 // 436 // For instance, the <video> widget controls rely on mozSystemGroup events 437 // to handle clicks on their UI elements. 438 // 439 // In general we need to prevent events from both groups, as well as 440 // handle a few events such as `click` to actually pick nodes. 441 // 442 // However events from the default group are resolved before the events 443 // from the mozSystemGroup. 444 // See https://searchfox.org/mozilla-central/rev/a85b25946f7f8eebf466bd7ad821b82b68a9231f/dom/events/EventDispatcher.cpp#652 445 // 446 // Therefore we need to make sure that we only "stop picking" in the 447 // mozSystemGroup event listeners. When we stop picking, we will remove 448 // the listeners added here, and if we do it too early, some unexpected 449 // callbacks might still be triggered. 450 // 451 // For instance, if we were to stop picking in the default group "click" 452 // event, then the mozSystemGroup "click" event would no longer be stopped 453 // by our listeners, and some widget callbacks might be triggered, such as 454 // <video> controls. 455 // 456 // As a summary: content listeners are resolved before mozSystemGroup 457 // events, so we only prevent content listeners and handle the pick logic 458 // at the latest point possible, in the mozSystemGroup listeners. 459 460 // 1. Prevent content events. 461 target.addEventListener(type, this._preventContentEvent, { 462 capture: true, 463 signal: this.#eventListenersAbortController.signal, 464 }); 465 466 // 2. Prevent mozSystemGroup events and handle pick logic. 467 target.addEventListener(type, handler, { 468 capture: true, 469 mozSystemGroup: true, 470 signal: this.#eventListenersAbortController.signal, 471 }); 472 } 473 474 this._setSuppressedEventListener(this._onSuppressedEvent); 475 } 476 477 _stopPickerListeners() { 478 this._setSuppressedEventListener(null); 479 480 if (this.#eventListenersAbortController) { 481 this.#eventListenersAbortController.abort(); 482 this.#eventListenersAbortController = null; 483 } 484 } 485 486 _stopPicking() { 487 this._stopPickerListeners(); 488 this._isPicking = false; 489 this._hoveredNode = null; 490 this._lastMouseMoveEvent = null; 491 if (this.#remoteNodePickerNoticeHighlighter) { 492 this.#remoteNodePickerNoticeHighlighter.hide(); 493 } 494 } 495 496 cancelPick() { 497 if (this._targetActor.threadActor) { 498 this._targetActor.threadActor.showOverlay(); 499 } 500 501 if (this._isPicking) { 502 this._stopPicking(); 503 } 504 } 505 506 pick(doFocus = false, isLocalTab = true) { 507 if (this._targetActor.threadActor) { 508 this._targetActor.threadActor.hideOverlay(); 509 } 510 511 if (this._isPicking) { 512 return; 513 } 514 515 this._startPickerListeners(); 516 this._isPicking = true; 517 518 if (doFocus) { 519 this._targetActor.window.focus(); 520 } 521 522 if (!isLocalTab) { 523 this.remoteNodePickerNoticeHighlighter.show(); 524 } 525 } 526 527 resetHoveredNodeReference() { 528 this._hoveredNode = null; 529 } 530 531 destroy() { 532 this.cancelPick(); 533 534 this._targetActor = null; 535 this._walker = null; 536 this.#remoteNodePickerNoticeHighlighter = null; 537 } 538 } 539 540 exports.NodePicker = NodePicker;