markup.js (90294B)
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 flags = require("resource://devtools/shared/flags.js"); 8 const nodeConstants = require("resource://devtools/shared/dom-node-constants.js"); 9 const nodeFilterConstants = require("resource://devtools/shared/dom-node-filter-constants.js"); 10 const EventEmitter = require("resource://devtools/shared/event-emitter.js"); 11 const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); 12 const { PluralForm } = require("resource://devtools/shared/plural-form.js"); 13 const AutocompletePopup = require("resource://devtools/client/shared/autocomplete-popup.js"); 14 const KeyShortcuts = require("resource://devtools/client/shared/key-shortcuts.js"); 15 const { scrollIntoViewIfNeeded } = ChromeUtils.importESModule( 16 "resource://devtools/client/shared/scroll.mjs" 17 ); 18 const { PrefObserver } = require("resource://devtools/client/shared/prefs.js"); 19 const MarkupElementContainer = require("resource://devtools/client/inspector/markup/views/element-container.js"); 20 const MarkupReadOnlyContainer = require("resource://devtools/client/inspector/markup/views/read-only-container.js"); 21 const MarkupTextContainer = require("resource://devtools/client/inspector/markup/views/text-container.js"); 22 const RootContainer = require("resource://devtools/client/inspector/markup/views/root-container.js"); 23 const WalkerEventListener = require("resource://devtools/client/inspector/shared/walker-event-listener.js"); 24 25 loader.lazyRequireGetter( 26 this, 27 ["createDOMMutationBreakpoint", "deleteDOMMutationBreakpoint"], 28 "resource://devtools/client/framework/actions/index.js", 29 true 30 ); 31 loader.lazyRequireGetter( 32 this, 33 "MarkupContextMenu", 34 "resource://devtools/client/inspector/markup/markup-context-menu.js" 35 ); 36 loader.lazyRequireGetter( 37 this, 38 "SlottedNodeContainer", 39 "resource://devtools/client/inspector/markup/views/slotted-node-container.js" 40 ); 41 loader.lazyRequireGetter( 42 this, 43 "getLongString", 44 "resource://devtools/client/inspector/shared/utils.js", 45 true 46 ); 47 loader.lazyRequireGetter( 48 this, 49 "openContentLink", 50 "resource://devtools/client/shared/link.js", 51 true 52 ); 53 loader.lazyRequireGetter( 54 this, 55 "HTMLTooltip", 56 "resource://devtools/client/shared/widgets/tooltip/HTMLTooltip.js", 57 true 58 ); 59 loader.lazyRequireGetter( 60 this, 61 "UndoStack", 62 "resource://devtools/client/shared/undo.js", 63 true 64 ); 65 loader.lazyRequireGetter( 66 this, 67 "clipboardHelper", 68 "resource://devtools/shared/platform/clipboard.js" 69 ); 70 loader.lazyRequireGetter( 71 this, 72 "beautify", 73 "resource://devtools/shared/jsbeautify/beautify.js" 74 ); 75 loader.lazyRequireGetter( 76 this, 77 "getTabPrefs", 78 "resource://devtools/shared/indentation.js", 79 true 80 ); 81 82 const INSPECTOR_L10N = new LocalizationHelper( 83 "devtools/client/locales/inspector.properties" 84 ); 85 86 // Page size for pageup/pagedown 87 const PAGE_SIZE = 10; 88 const DEFAULT_MAX_CHILDREN = 100; 89 const DRAG_DROP_AUTOSCROLL_EDGE_MAX_DISTANCE = 50; 90 const DRAG_DROP_AUTOSCROLL_EDGE_RATIO = 0.1; 91 const DRAG_DROP_MIN_AUTOSCROLL_SPEED = 2; 92 const DRAG_DROP_MAX_AUTOSCROLL_SPEED = 8; 93 const DRAG_DROP_HEIGHT_TO_SPEED = 500; 94 const DRAG_DROP_HEIGHT_TO_SPEED_MIN = 0.5; 95 const DRAG_DROP_HEIGHT_TO_SPEED_MAX = 1; 96 const ATTR_COLLAPSE_ENABLED_PREF = "devtools.markup.collapseAttributes"; 97 const ATTR_COLLAPSE_LENGTH_PREF = "devtools.markup.collapseAttributeLength"; 98 const BEAUTIFY_HTML_ON_COPY_PREF = "devtools.markup.beautifyOnCopy"; 99 100 /** 101 * These functions are called when a shortcut (as defined in `_initShortcuts`) occurs. 102 * Each property in the following object corresponds to one of the shortcut that is 103 * handled by the markup-view. 104 * Each property value is a function that takes the markup-view instance as only 105 * argument, and returns a boolean that signifies whether the event should be consumed. 106 * By default, the event gets consumed after the shortcut handler returns, 107 * this means its propagation is stopped. If you do want the shortcut event 108 * to continue propagating through DevTools, then return true from the handler. 109 */ 110 const shortcutHandlers = { 111 // Localizable keys 112 "markupView.hide.key": markupView => { 113 const node = markupView._selectedContainer.node; 114 const walkerFront = node.walkerFront; 115 116 if (node.hidden) { 117 walkerFront.unhideNode(node); 118 } else { 119 walkerFront.hideNode(node); 120 } 121 }, 122 "markupView.edit.key": markupView => { 123 markupView.beginEditingHTML(markupView._selectedContainer.node); 124 }, 125 "markupView.scrollInto.key": markupView => { 126 markupView.scrollNodeIntoView(); 127 }, 128 // Generic keys 129 Delete: markupView => { 130 markupView.deleteNodeOrAttribute(); 131 }, 132 Backspace: markupView => { 133 markupView.deleteNodeOrAttribute(true); 134 }, 135 Home: markupView => { 136 const rootContainer = markupView.getContainer(markupView._rootNode); 137 markupView.navigate(rootContainer.children.firstChild.container); 138 }, 139 Left: markupView => { 140 if (markupView._selectedContainer.expanded) { 141 markupView.collapseNode(markupView._selectedContainer.node); 142 } else { 143 const parent = markupView._selectionWalker().parentNode(); 144 if (parent) { 145 markupView.navigate(parent.container); 146 } 147 } 148 }, 149 Right: markupView => { 150 if ( 151 !markupView._selectedContainer.expanded && 152 markupView._selectedContainer.hasChildren 153 ) { 154 markupView._expandContainer(markupView._selectedContainer); 155 } else { 156 const next = markupView._selectionWalker().nextNode(); 157 if (next) { 158 markupView.navigate(next.container); 159 } 160 } 161 }, 162 Up: markupView => { 163 const previousNode = markupView._selectionWalker().previousNode(); 164 if (previousNode) { 165 markupView.navigate(previousNode.container); 166 } 167 }, 168 Down: markupView => { 169 const nextNode = markupView._selectionWalker().nextNode(); 170 if (nextNode) { 171 markupView.navigate(nextNode.container); 172 } 173 }, 174 PageUp: markupView => { 175 const walker = markupView._selectionWalker(); 176 let selection = markupView._selectedContainer; 177 for (let i = 0; i < PAGE_SIZE; i++) { 178 const previousNode = walker.previousNode(); 179 if (!previousNode) { 180 break; 181 } 182 selection = previousNode.container; 183 } 184 markupView.navigate(selection); 185 }, 186 PageDown: markupView => { 187 const walker = markupView._selectionWalker(); 188 let selection = markupView._selectedContainer; 189 for (let i = 0; i < PAGE_SIZE; i++) { 190 const nextNode = walker.nextNode(); 191 if (!nextNode) { 192 break; 193 } 194 selection = nextNode.container; 195 } 196 markupView.navigate(selection); 197 }, 198 Enter: markupView => { 199 if (!markupView._selectedContainer.canFocus) { 200 markupView._selectedContainer.canFocus = true; 201 markupView._selectedContainer.focus(); 202 return false; 203 } 204 return true; 205 }, 206 Space: markupView => { 207 if (!markupView._selectedContainer.canFocus) { 208 markupView._selectedContainer.canFocus = true; 209 markupView._selectedContainer.focus(); 210 return false; 211 } 212 return true; 213 }, 214 Esc: markupView => { 215 if (markupView.isDragging) { 216 markupView.cancelDragging(); 217 return false; 218 } 219 // Prevent cancelling the event when not 220 // dragging, to allow the split console to be toggled. 221 return true; 222 }, 223 }; 224 225 /** 226 * Vocabulary for the purposes of this file: 227 * 228 * MarkupContainer - the structure that holds an editor and its 229 * immediate children in the markup panel. 230 * - MarkupElementContainer: markup container for element nodes 231 * - MarkupTextContainer: markup container for text / comment nodes 232 * - MarkupReadonlyContainer: markup container for other nodes 233 * Node - A content node. 234 * object.elt - A UI element in the markup panel. 235 */ 236 237 /** 238 * The markup tree. Manages the mapping of nodes to MarkupContainers, 239 * updating based on mutations, and the undo/redo bindings. 240 */ 241 class MarkupView extends EventEmitter { 242 /** 243 * @param {Inspector} inspector 244 * The inspector we're watching. 245 * @param {iframe} frame 246 * An iframe in which the caller has kindly loaded markup.xhtml. 247 * @param {XULWindow} controllerWindow 248 * Will enable the undo/redo feature from devtools/client/shared/undo. 249 * Should be a XUL window, will typically point to the toolbox window. 250 */ 251 constructor(inspector, frame, controllerWindow) { 252 super(); 253 254 this.controllerWindow = controllerWindow; 255 this.inspector = inspector; 256 this.highlighters = inspector.highlighters; 257 this.walker = this.inspector.walker; 258 this._frame = frame; 259 this.win = this._frame.contentWindow; 260 this.doc = this._frame.contentDocument; 261 this._elt = this.doc.getElementById("root"); 262 this.telemetry = this.inspector.telemetry; 263 this._breakpointIDsInLocalState = new Map(); 264 this._containersToUpdate = new Map(); 265 266 this.maxChildren = Services.prefs.getIntPref( 267 "devtools.markup.pagesize", 268 DEFAULT_MAX_CHILDREN 269 ); 270 271 this.collapseAttributes = Services.prefs.getBoolPref( 272 ATTR_COLLAPSE_ENABLED_PREF 273 ); 274 this.collapseAttributeLength = Services.prefs.getIntPref( 275 ATTR_COLLAPSE_LENGTH_PREF 276 ); 277 278 // Creating the popup to be used to show CSS suggestions. 279 // The popup will be attached to the toolbox document. 280 this.popup = new AutocompletePopup(inspector.toolbox.doc, { 281 autoSelect: true, 282 }); 283 284 this._containers = new Map(); 285 // This weakmap will hold keys used with the _containers map, in order to retrieve the 286 // slotted container for a given node front. 287 this._slottedContainerKeys = new WeakMap(); 288 289 // Binding functions that need to be called in scope. 290 this._handleRejectionIfNotDestroyed = 291 this._handleRejectionIfNotDestroyed.bind(this); 292 this._isImagePreviewTarget = this._isImagePreviewTarget.bind(this); 293 this._onWalkerMutations = this._onWalkerMutations.bind(this); 294 this._onBlur = this._onBlur.bind(this); 295 this._onContextMenu = this._onContextMenu.bind(this); 296 this._onCopy = this._onCopy.bind(this); 297 this._onCollapseAttributesPrefChange = 298 this._onCollapseAttributesPrefChange.bind(this); 299 this._onWalkerNodeStatesChanged = 300 this._onWalkerNodeStatesChanged.bind(this); 301 this._onFocus = this._onFocus.bind(this); 302 this._onResourceAvailable = this._onResourceAvailable.bind(this); 303 this._onTargetAvailable = this._onTargetAvailable.bind(this); 304 this._onTargetDestroyed = this._onTargetDestroyed.bind(this); 305 this._onMouseClick = this._onMouseClick.bind(this); 306 this._onMouseMove = this._onMouseMove.bind(this); 307 this._onMouseOut = this._onMouseOut.bind(this); 308 this._onMouseUp = this._onMouseUp.bind(this); 309 this._onNewSelection = this._onNewSelection.bind(this); 310 this._onToolboxPickerCanceled = this._onToolboxPickerCanceled.bind(this); 311 this._onToolboxPickerHover = this._onToolboxPickerHover.bind(this); 312 this._onDomMutation = this._onDomMutation.bind(this); 313 this._updateSearchResultsHighlightingInSelectedNode = 314 this._updateSearchResultsHighlightingInSelectedNode.bind(this); 315 this._onToolboxSelect = this._onToolboxSelect.bind(this); 316 317 // Listening to various events. 318 this._elt.addEventListener("blur", this._onBlur, true); 319 this._elt.addEventListener("click", this._onMouseClick); 320 this._elt.addEventListener("contextmenu", this._onContextMenu); 321 this._elt.addEventListener("mousemove", this._onMouseMove); 322 this._elt.addEventListener("mouseout", this._onMouseOut); 323 this._frame.addEventListener("focus", this._onFocus); 324 this.inspector.selection.on("new-node-front", this._onNewSelection); 325 this.inspector.on( 326 "search-cleared", 327 this._updateSearchResultsHighlightingInSelectedNode 328 ); 329 this._unsubscribeFromToolboxStore = this.inspector.toolbox.store.subscribe( 330 this._onDomMutation 331 ); 332 333 if (flags.testing) { 334 // In tests, we start listening immediately to avoid having to simulate a mousemove. 335 this._initTooltips(); 336 } 337 338 this.win.addEventListener("copy", this._onCopy); 339 this.win.addEventListener("mouseup", this._onMouseUp); 340 this.inspector.toolbox.nodePicker.on( 341 "picker-node-canceled", 342 this._onToolboxPickerCanceled 343 ); 344 this.inspector.toolbox.nodePicker.on( 345 "picker-node-hovered", 346 this._onToolboxPickerHover 347 ); 348 349 // Event listeners for highlighter events 350 this.onHighlighterShown = data => 351 this.handleHighlighterEvent("highlighter-shown", data); 352 this.onHighlighterHidden = data => 353 this.handleHighlighterEvent("highlighter-hidden", data); 354 this.inspector.highlighters.on( 355 "highlighter-shown", 356 this.onHighlighterShown 357 ); 358 this.inspector.highlighters.on( 359 "highlighter-hidden", 360 this.onHighlighterHidden 361 ); 362 this.inspector.toolbox.once("select", this._onToolboxSelect); 363 364 this._onNewSelection(this.inspector.selection.nodeFront); 365 if (this.inspector.selection.nodeFront) { 366 this.expandNode(this.inspector.selection.nodeFront); 367 } 368 369 this._prefObserver = new PrefObserver("devtools.markup"); 370 this._prefObserver.on( 371 ATTR_COLLAPSE_ENABLED_PREF, 372 this._onCollapseAttributesPrefChange 373 ); 374 this._prefObserver.on( 375 ATTR_COLLAPSE_LENGTH_PREF, 376 this._onCollapseAttributesPrefChange 377 ); 378 379 this._initShortcuts(); 380 381 this._walkerEventListener = new WalkerEventListener(this.inspector, { 382 "anchor-name-change": this._onWalkerNodeStatesChanged, 383 "container-type-change": this._onWalkerNodeStatesChanged, 384 "display-change": this._onWalkerNodeStatesChanged, 385 "scrollable-change": this._onWalkerNodeStatesChanged, 386 "overflow-change": this._onWalkerNodeStatesChanged, 387 mutations: this._onWalkerMutations, 388 }); 389 390 this.resourceCommand = this.inspector.commands.resourceCommand; 391 this.resourceCommand.watchResources( 392 [this.resourceCommand.TYPES.ROOT_NODE], 393 { 394 onAvailable: this._onResourceAvailable, 395 } 396 ); 397 398 this.targetCommand = this.inspector.commands.targetCommand; 399 this.targetCommand.watchTargets({ 400 types: [this.targetCommand.TYPES.FRAME], 401 onAvailable: this._onTargetAvailable, 402 onDestroyed: this._onTargetDestroyed, 403 }); 404 } 405 406 /** 407 * How long does a node flash when it mutates (in ms). 408 */ 409 CONTAINER_FLASHING_DURATION = 500; 410 411 _selectedContainer = null; 412 413 get contextMenu() { 414 if (!this._contextMenu) { 415 this._contextMenu = new MarkupContextMenu(this); 416 } 417 418 return this._contextMenu; 419 } 420 421 hasEventDetailsTooltip() { 422 return !!this._eventDetailsTooltip; 423 } 424 425 get eventDetailsTooltip() { 426 if (!this._eventDetailsTooltip) { 427 // This tooltip will be attached to the toolbox document. 428 this._eventDetailsTooltip = new HTMLTooltip(this.toolbox.doc, { 429 type: "arrow", 430 consumeOutsideClicks: false, 431 }); 432 } 433 434 return this._eventDetailsTooltip; 435 } 436 437 get toolbox() { 438 return this.inspector.toolbox; 439 } 440 441 get undo() { 442 if (!this._undo) { 443 this._undo = new UndoStack(); 444 this._undo.installController(this.controllerWindow); 445 } 446 447 return this._undo; 448 } 449 450 _onDomMutation() { 451 const domMutationBreakpoints = 452 this.inspector.toolbox.store.getState().domMutationBreakpoints 453 .breakpoints; 454 const breakpointIDsInCurrentState = []; 455 for (const breakpoint of domMutationBreakpoints) { 456 const nodeFront = breakpoint.nodeFront; 457 const mutationType = breakpoint.mutationType; 458 const enabledStatus = breakpoint.enabled; 459 breakpointIDsInCurrentState.push(breakpoint.id); 460 // If breakpoint is not in local state 461 if (!this._breakpointIDsInLocalState.has(breakpoint.id)) { 462 this._breakpointIDsInLocalState.set(breakpoint.id, breakpoint); 463 if (!this._containersToUpdate.has(nodeFront)) { 464 this._containersToUpdate.set(nodeFront, new Map()); 465 } 466 } 467 this._containersToUpdate.get(nodeFront).set(mutationType, enabledStatus); 468 } 469 // If a breakpoint is in local state but not current state, it has been 470 // removed by the user. 471 for (const id of this._breakpointIDsInLocalState.keys()) { 472 if (breakpointIDsInCurrentState.includes(id) === false) { 473 const nodeFront = this._breakpointIDsInLocalState.get(id).nodeFront; 474 const mutationType = 475 this._breakpointIDsInLocalState.get(id).mutationType; 476 this._containersToUpdate.get(nodeFront).delete(mutationType); 477 this._breakpointIDsInLocalState.delete(id); 478 } 479 } 480 // Update each container 481 for (const nodeFront of this._containersToUpdate.keys()) { 482 const mutationBreakpoints = this._containersToUpdate.get(nodeFront); 483 const container = this.getContainer(nodeFront); 484 container.update(mutationBreakpoints); 485 if (this._containersToUpdate.get(nodeFront).size === 0) { 486 this._containersToUpdate.delete(nodeFront); 487 } 488 } 489 } 490 491 /** 492 * Handle promise rejections for various asynchronous actions, and only log errors if 493 * the markup view still exists. 494 * This is useful to silence useless errors that happen when the markup view is 495 * destroyed while still initializing (and making protocol requests). 496 */ 497 _handleRejectionIfNotDestroyed(e) { 498 if (!this._destroyed) { 499 console.error(e); 500 } 501 } 502 503 _initTooltips() { 504 if (this.imagePreviewTooltip) { 505 return; 506 } 507 // The tooltips will be attached to the toolbox document. 508 this.imagePreviewTooltip = new HTMLTooltip(this.toolbox.doc, { 509 type: "arrow", 510 useXulWrapper: true, 511 }); 512 this._enableImagePreviewTooltip(); 513 } 514 515 _enableImagePreviewTooltip() { 516 if (!this.imagePreviewTooltip) { 517 return; 518 } 519 this.imagePreviewTooltip.startTogglingOnHover( 520 this._elt, 521 this._isImagePreviewTarget 522 ); 523 } 524 525 _disableImagePreviewTooltip() { 526 if (!this.imagePreviewTooltip) { 527 return; 528 } 529 this.imagePreviewTooltip.stopTogglingOnHover(); 530 } 531 532 _onToolboxPickerHover(nodeFront) { 533 this.showNode(nodeFront).then(() => { 534 this._showNodeAsHovered(nodeFront); 535 }, console.error); 536 } 537 538 /** 539 * If the element picker gets canceled, make sure and re-center the view on the 540 * current selected element. 541 */ 542 _onToolboxPickerCanceled() { 543 if (this._selectedContainer) { 544 scrollIntoViewIfNeeded(this._selectedContainer.editor.elt); 545 } 546 } 547 548 _onToolboxSelect(id) { 549 if (id !== "inspector") { 550 return; 551 } 552 553 // If the inspector was opened from the "Inspect" context menu, the node gets selected 554 // in the MarkupView constructor, but the Toolbox focuses the Inspector iframe once 555 // the tool is loaded (and the iframe is actually visible), so we need to focus 556 // the selected node after the inspector was properly selected and focused (See Bug 1979591). 557 if (this.inspector.selection?.reason === "browser-context-menu") { 558 this.maybeNavigateToNewSelection(); 559 } 560 } 561 562 isDragging = false; 563 _draggedContainer = null; 564 565 _onMouseMove(event) { 566 // Note that in tests, we start listening immediately from the constructor to avoid having to simulate a mousemove. 567 // Also note that initTooltips bails out if it is called many times, so it isn't an issue to call it a second 568 // time from here in case tests are doing a mousemove. 569 this._initTooltips(); 570 571 let target = event.target; 572 573 if (this._draggedContainer) { 574 this._draggedContainer.onMouseMove(event); 575 } 576 // Auto-scroll if we're dragging. 577 if (this.isDragging) { 578 event.preventDefault(); 579 this._autoScroll(event); 580 return; 581 } 582 583 // Show the current container as hovered and highlight it. 584 // This requires finding the current MarkupContainer (walking up the DOM). 585 while (!target.container) { 586 if (target.tagName.toLowerCase() === "body") { 587 return; 588 } 589 target = target.parentNode; 590 } 591 592 const container = target.container; 593 if (this._hoveredContainer !== container) { 594 this._showBoxModel(container.node); 595 } 596 this._showContainerAsHovered(container); 597 598 this.emit("node-hover"); 599 } 600 601 /** 602 * If focus is moved outside of the markup view document and there is a 603 * selected container, make its contents not focusable by a keyboard. 604 */ 605 _onBlur(event) { 606 if (!this._selectedContainer) { 607 return; 608 } 609 610 const { relatedTarget } = event; 611 if (relatedTarget && relatedTarget.ownerDocument === this.doc) { 612 return; 613 } 614 615 if (this._selectedContainer) { 616 this._selectedContainer.clearFocus(); 617 } 618 } 619 620 _onContextMenu(event) { 621 this.contextMenu.show(event); 622 } 623 624 /** 625 * Executed on each mouse-move while a node is being dragged in the view. 626 * Auto-scrolls the view to reveal nodes below the fold to drop the dragged 627 * node in. 628 */ 629 _autoScroll(event) { 630 const docEl = this.doc.documentElement; 631 632 if (this._autoScrollAnimationFrame) { 633 this.win.cancelAnimationFrame(this._autoScrollAnimationFrame); 634 } 635 636 // Auto-scroll when the mouse approaches top/bottom edge. 637 const fromBottom = docEl.clientHeight - event.pageY + this.win.scrollY; 638 const fromTop = event.pageY - this.win.scrollY; 639 const edgeDistance = Math.min( 640 DRAG_DROP_AUTOSCROLL_EDGE_MAX_DISTANCE, 641 docEl.clientHeight * DRAG_DROP_AUTOSCROLL_EDGE_RATIO 642 ); 643 644 // The smaller the screen, the slower the movement. 645 const heightToSpeedRatio = Math.max( 646 DRAG_DROP_HEIGHT_TO_SPEED_MIN, 647 Math.min( 648 DRAG_DROP_HEIGHT_TO_SPEED_MAX, 649 docEl.clientHeight / DRAG_DROP_HEIGHT_TO_SPEED 650 ) 651 ); 652 653 if (fromBottom <= edgeDistance) { 654 // Map our distance range to a speed range so that the speed is not too 655 // fast or too slow. 656 const speed = map( 657 fromBottom, 658 0, 659 edgeDistance, 660 DRAG_DROP_MIN_AUTOSCROLL_SPEED, 661 DRAG_DROP_MAX_AUTOSCROLL_SPEED 662 ); 663 664 this._runUpdateLoop(() => { 665 docEl.scrollTop -= 666 heightToSpeedRatio * (speed - DRAG_DROP_MAX_AUTOSCROLL_SPEED); 667 }); 668 } 669 670 if (fromTop <= edgeDistance) { 671 const speed = map( 672 fromTop, 673 0, 674 edgeDistance, 675 DRAG_DROP_MIN_AUTOSCROLL_SPEED, 676 DRAG_DROP_MAX_AUTOSCROLL_SPEED 677 ); 678 679 this._runUpdateLoop(() => { 680 docEl.scrollTop += 681 heightToSpeedRatio * (speed - DRAG_DROP_MAX_AUTOSCROLL_SPEED); 682 }); 683 } 684 } 685 686 /** 687 * Run a loop on the requestAnimationFrame. 688 */ 689 _runUpdateLoop(update) { 690 const loop = () => { 691 update(); 692 this._autoScrollAnimationFrame = this.win.requestAnimationFrame(loop); 693 }; 694 loop(); 695 } 696 697 _onMouseClick(event) { 698 // From the target passed here, let's find the parent MarkupContainer 699 // and forward the event if needed. 700 let parentNode = event.target; 701 let container; 702 while (parentNode !== this.doc.body) { 703 if (parentNode.container) { 704 container = parentNode.container; 705 break; 706 } 707 parentNode = parentNode.parentNode; 708 } 709 710 if (typeof container.onContainerClick === "function") { 711 // Forward the event to the container if it implements onContainerClick. 712 container.onContainerClick(event); 713 } 714 } 715 716 _onMouseUp(event) { 717 if (this._draggedContainer) { 718 this._draggedContainer.onMouseUp(event); 719 } 720 721 this.indicateDropTarget(null); 722 this.indicateDragTarget(null); 723 if (this._autoScrollAnimationFrame) { 724 this.win.cancelAnimationFrame(this._autoScrollAnimationFrame); 725 } 726 } 727 728 _onCollapseAttributesPrefChange() { 729 this.collapseAttributes = Services.prefs.getBoolPref( 730 ATTR_COLLAPSE_ENABLED_PREF 731 ); 732 this.collapseAttributeLength = Services.prefs.getIntPref( 733 ATTR_COLLAPSE_LENGTH_PREF 734 ); 735 this.update(); 736 } 737 738 cancelDragging() { 739 if (!this.isDragging) { 740 return; 741 } 742 743 for (const [, container] of this._containers) { 744 if (container.isDragging) { 745 container.cancelDragging(); 746 break; 747 } 748 } 749 750 this.indicateDropTarget(null); 751 this.indicateDragTarget(null); 752 if (this._autoScrollAnimationFrame) { 753 this.win.cancelAnimationFrame(this._autoScrollAnimationFrame); 754 } 755 } 756 757 _hoveredContainer = null; 758 759 /** 760 * Show a NodeFront's container as being hovered 761 * 762 * @param {NodeFront} nodeFront 763 * The node to show as hovered 764 */ 765 _showNodeAsHovered(nodeFront) { 766 const container = this.getContainer(nodeFront); 767 this._showContainerAsHovered(container); 768 } 769 770 _showContainerAsHovered(container) { 771 if (this._hoveredContainer === container) { 772 return; 773 } 774 775 if (this._hoveredContainer) { 776 this._hoveredContainer.hovered = false; 777 } 778 779 container.hovered = true; 780 this._hoveredContainer = container; 781 } 782 783 async _onMouseOut(event) { 784 // Emulate mouseleave by skipping any relatedTarget inside the markup-view. 785 if (this._elt.contains(event.relatedTarget)) { 786 return; 787 } 788 789 if (this._autoScrollAnimationFrame) { 790 this.win.cancelAnimationFrame(this._autoScrollAnimationFrame); 791 } 792 if (this.isDragging) { 793 return; 794 } 795 796 await this._hideBoxModel(); 797 if (this._hoveredContainer) { 798 this._hoveredContainer.hovered = false; 799 } 800 this._hoveredContainer = null; 801 802 this.emit("leave"); 803 } 804 805 /** 806 * Show the Box Model Highlighter on a given node front 807 * 808 * @param {NodeFront} nodeFront 809 * The node for which to show the highlighter. 810 * @param {object} options 811 * Configuration object with options for the Box Model Highlighter. 812 * @return {Promise} Resolves after the highlighter for this nodeFront is shown. 813 */ 814 _showBoxModel(nodeFront, options) { 815 return this.inspector.highlighters.showHighlighterTypeForNode( 816 this.inspector.highlighters.TYPES.BOXMODEL, 817 nodeFront, 818 options 819 ); 820 } 821 822 /** 823 * Hide the Box Model Highlighter for any node that may be highlighted. 824 * 825 * @return {Promise} Resolves when the highlighter is hidden. 826 */ 827 _hideBoxModel() { 828 return this.inspector.highlighters.hideHighlighterType( 829 this.inspector.highlighters.TYPES.BOXMODEL 830 ); 831 } 832 833 /** 834 * Delegate handler for highlighter events. 835 * 836 * This is the place to observe for highlighter events, check the highlighter type and 837 * event name, then react for example by modifying the DOM. 838 * 839 * @param {string} eventName 840 * Highlighter event name. One of: "highlighter-hidden", "highlighter-shown" 841 * @param {object} data 842 * Object with data associated with the highlighter event. 843 * {String} data.type 844 * Highlighter type 845 * {NodeFront} data.nodeFront 846 * NodeFront of the node associated with the highlighter event 847 * {Object} data.options 848 * Optional configuration passed to the highlighter when shown 849 * {CustomHighlighterFront} data.highlighter 850 * Highlighter instance 851 */ 852 handleHighlighterEvent(eventName, data) { 853 switch (data.type) { 854 // Toggle the "active" CSS class name on flex and grid display badges next to 855 // elements in the Markup view when a coresponding flex or grid highlighter is 856 // shown or hidden for a node. 857 case this.inspector.highlighters.TYPES.FLEXBOX: 858 case this.inspector.highlighters.TYPES.GRID: { 859 const { nodeFront } = data; 860 if (!nodeFront) { 861 return; 862 } 863 864 // Find the badge corresponding to the node from the highlighter event payload. 865 const container = this.getContainer(nodeFront); 866 const badge = container?.editor?.displayBadge; 867 if (badge) { 868 const isActive = eventName == "highlighter-shown"; 869 badge.classList.toggle("active", isActive); 870 badge.setAttribute("aria-pressed", isActive); 871 } 872 873 // There is a limit to how many grid highlighters can be active at the same time. 874 // If the limit was reached, disable all non-active grid badges. 875 if (data.type === this.inspector.highlighters.TYPES.GRID) { 876 // Matches badges for "grid", "inline-grid" and "subgrid" 877 const selector = "[data-display*='grid']:not(.active)"; 878 const isLimited = 879 this.inspector.highlighters.isGridHighlighterLimitReached(); 880 Array.from(this._elt.querySelectorAll(selector)).map(el => { 881 el.classList.toggle("interactive", !isLimited); 882 }); 883 } 884 break; 885 } 886 } 887 } 888 889 /** 890 * Used by tests 891 */ 892 getSelectedContainer() { 893 return this._selectedContainer; 894 } 895 896 /** 897 * Get the MarkupContainer object for a given node, or undefined if 898 * none exists. 899 * 900 * @param {NodeFront} nodeFront 901 * The node to get the container for. 902 * @param {boolean} slotted 903 * true to get the slotted version of the container. 904 * @return {MarkupContainer} The container for the provided node. 905 */ 906 getContainer(node, slotted) { 907 const key = this._getContainerKey(node, slotted); 908 return this._containers.get(key); 909 } 910 911 /** 912 * Register a given container for a given node/slotted node. 913 * 914 * @param {NodeFront} nodeFront 915 * The node to set the container for. 916 * @param {boolean} slotted 917 * true if the container represents the slotted version of the node. 918 */ 919 setContainer(node, container, slotted) { 920 const key = this._getContainerKey(node, slotted); 921 return this._containers.set(key, container); 922 } 923 924 /** 925 * Check if a MarkupContainer object exists for a given node/slotted node 926 * 927 * @param {NodeFront} nodeFront 928 * The node to check. 929 * @param {boolean} slotted 930 * true to check for a container matching the slotted version of the node. 931 * @return {boolean} True if a container exists, false otherwise. 932 */ 933 hasContainer(node, slotted) { 934 const key = this._getContainerKey(node, slotted); 935 return this._containers.has(key); 936 } 937 938 _getContainerKey(node, slotted) { 939 if (!slotted) { 940 return node; 941 } 942 943 if (!this._slottedContainerKeys.has(node)) { 944 this._slottedContainerKeys.set(node, { node }); 945 } 946 return this._slottedContainerKeys.get(node); 947 } 948 949 _isContainerSelected(container) { 950 if (!container) { 951 return false; 952 } 953 954 const selection = this.inspector.selection; 955 return ( 956 container.node == selection.nodeFront && 957 container.isSlotted() == selection.isSlotted() 958 ); 959 } 960 961 update() { 962 const updateChildren = node => { 963 this.getContainer(node).update(); 964 for (const child of node.treeChildren()) { 965 updateChildren(child); 966 } 967 }; 968 969 // Start with the documentElement 970 let documentElement; 971 for (const node of this._rootNode.treeChildren()) { 972 if (node.isDocumentElement === true) { 973 documentElement = node; 974 break; 975 } 976 } 977 978 // Recursively update each node starting with documentElement. 979 updateChildren(documentElement); 980 } 981 982 /** 983 * Executed when the mouse hovers over a target in the markup-view and is used 984 * to decide whether this target should be used to display an image preview 985 * tooltip. 986 * Delegates the actual decision to the corresponding MarkupContainer instance 987 * if one is found. 988 * 989 * @return {Promise} the promise returned by 990 * MarkupElementContainer._isImagePreviewTarget 991 */ 992 async _isImagePreviewTarget(target) { 993 // From the target passed here, let's find the parent MarkupContainer 994 // and ask it if the tooltip should be shown 995 if (this.isDragging) { 996 return false; 997 } 998 999 let parent = target, 1000 container; 1001 while (parent) { 1002 if (parent.container) { 1003 container = parent.container; 1004 break; 1005 } 1006 parent = parent.parentNode; 1007 } 1008 1009 if (container instanceof MarkupElementContainer) { 1010 return container.isImagePreviewTarget(target, this.imagePreviewTooltip); 1011 } 1012 1013 return false; 1014 } 1015 1016 /** 1017 * Given the known reason, should the current selection be briefly highlighted 1018 * In a few cases, we don't want to highlight the node: 1019 * - If the reason is null (used to reset the selection), 1020 * - if it's "inspector-default-selection" (initial node selected, either when 1021 * opening the inspector or after a navigation/reload) 1022 * - if it's "picker-node-picked" or "picker-node-previewed" (node selected with the 1023 * node picker. Note that this does not include the "Inspect element" context menu, 1024 * which has a dedicated reason, "browser-context-menu"). 1025 * - if it's "test" (this is a special case for mochitest. In tests, we often 1026 * need to select elements but don't necessarily want the highlighter to come 1027 * and go after a delay as this might break test scenarios) 1028 * We also do not want to start a brief highlight timeout if the node is 1029 * already being hovered over, since in that case it will already be 1030 * highlighted. 1031 */ 1032 _shouldNewSelectionBeHighlighted() { 1033 const reason = this.inspector.selection.reason; 1034 const unwantedReasons = [ 1035 "inspector-default-selection", 1036 "nodeselected", 1037 "picker-node-picked", 1038 "picker-node-previewed", 1039 "test", 1040 ]; 1041 1042 const isHighlight = this._isContainerSelected(this._hoveredContainer); 1043 return !isHighlight && reason && !unwantedReasons.includes(reason); 1044 } 1045 1046 /** 1047 * React to new-node-front selection events. 1048 * Highlights the node if needed, and make sure it is shown and selected in 1049 * the view. 1050 * Note that this might be called when the panel is initialized to properly setup 1051 * all the listeners. 1052 * 1053 * @param {NodeFront|undefined} nodeFront 1054 * @param {string | undefined} reason 1055 */ 1056 _onNewSelection(nodeFront, reason) { 1057 const selection = this.inspector.selection; 1058 // this will probably leak. 1059 // TODO: use resource api listeners? 1060 if (nodeFront) { 1061 nodeFront.walkerFront.on( 1062 "anchor-name-change", 1063 this._onWalkerNodeStatesChanged 1064 ); 1065 nodeFront.walkerFront.on( 1066 "container-type-change", 1067 this._onWalkerNodeStatesChanged 1068 ); 1069 nodeFront.walkerFront.on( 1070 "display-change", 1071 this._onWalkerNodeStatesChanged 1072 ); 1073 nodeFront.walkerFront.on( 1074 "scrollable-change", 1075 this._onWalkerNodeStatesChanged 1076 ); 1077 nodeFront.walkerFront.on( 1078 "overflow-change", 1079 this._onWalkerNodeStatesChanged 1080 ); 1081 nodeFront.walkerFront.on("mutations", this._onWalkerMutations); 1082 } 1083 1084 if (this.htmlEditor) { 1085 this.htmlEditor.hide(); 1086 } 1087 if (this._isContainerSelected(this._hoveredContainer)) { 1088 this._hoveredContainer.hovered = false; 1089 this._hoveredContainer = null; 1090 } 1091 1092 if (!selection.isNode()) { 1093 this.unmarkSelectedNode(); 1094 return; 1095 } 1096 1097 const done = this.inspector.updating("markup-view"); 1098 let onShowBoxModel; 1099 1100 // Highlight the element briefly if needed. 1101 if (this._shouldNewSelectionBeHighlighted()) { 1102 onShowBoxModel = this._showBoxModel(nodeFront, { 1103 duration: this.inspector.HIGHLIGHTER_AUTOHIDE_TIMER, 1104 }); 1105 } 1106 1107 const slotted = selection.isSlotted(); 1108 const smoothScroll = reason === "reveal-from-slot"; 1109 const selectionSearchQuery = selection.getSearchQuery(); 1110 1111 const onShow = this.showNode(selection.nodeFront, { 1112 slotted, 1113 smoothScroll, 1114 // Don't scroll if we selected the node from the search, we'll scroll to the first 1115 // matching Range done in _updateSearchResultsHighlightingInSelectedNode. 1116 // This need to be done there because the matching Range might be out of screen, 1117 // for example if the node is very tall, or if the markup view overflows horizontally 1118 // and the Range is located near the right end of the node container. 1119 scroll: !selectionSearchQuery, 1120 }) 1121 .then(() => { 1122 // We could be destroyed by now. 1123 if (this._destroyed) { 1124 return Promise.reject("markupview destroyed"); 1125 } 1126 1127 // Mark the node as selected. 1128 const container = this.getContainer(selection.nodeFront, slotted); 1129 this._markContainerAsSelected(container); 1130 this._updateSearchResultsHighlightingInSelectedNode( 1131 selectionSearchQuery 1132 ); 1133 1134 // Make sure the new selection is navigated to. 1135 this.maybeNavigateToNewSelection(); 1136 return undefined; 1137 }) 1138 .catch(this._handleRejectionIfNotDestroyed); 1139 1140 Promise.all([onShowBoxModel, onShow]).then(done); 1141 } 1142 1143 _getSearchResultsHighlight() { 1144 const highlightName = "devtools-search"; 1145 const highlights = this.win.CSS.highlights; 1146 1147 if (!highlights.has(highlightName)) { 1148 highlights.set(highlightName, new this.win.Highlight()); 1149 } 1150 1151 return highlights.get(highlightName); 1152 } 1153 1154 /** 1155 * @returns {nsISelectionController} 1156 */ 1157 _getSelectionController() { 1158 if (!this._selectionController) { 1159 // QueryInterface can be expensive, so cache the controller. 1160 this._selectionController = this.win.docShell 1161 .QueryInterface(Ci.nsIInterfaceRequestor) 1162 .getInterface(Ci.nsISelectionDisplay) 1163 .QueryInterface(Ci.nsISelectionController); 1164 } 1165 return this._selectionController; 1166 } 1167 1168 /** 1169 * Highlight search results in the markup view. 1170 * 1171 * @param {string | null} searchQuery: The search string we want to highlight. Pass null 1172 * to clear existing highlighting. 1173 */ 1174 _updateSearchResultsHighlightingInSelectedNode(searchQuery) { 1175 // Clear any existing search highlights 1176 const searchHighlight = this._getSearchResultsHighlight(); 1177 searchHighlight.clear(); 1178 1179 // If there's no selected container, or if the search is empty, we don't have anything 1180 // to highlight. 1181 if (!this._selectedContainer || !searchQuery) { 1182 this.emitForTests("search-results-highlighting-updated"); 1183 return; 1184 } 1185 1186 // Look for search string occurences in the tag 1187 const treeWalker = this.doc.createTreeWalker( 1188 this._selectedContainer.tagLine, 1189 NodeFilter.SHOW_TEXT 1190 ); 1191 searchQuery = searchQuery.toLowerCase(); 1192 const searchQueryLength = searchQuery.length; 1193 let currentNode = treeWalker.nextNode(); 1194 let scrolled = false; 1195 1196 while (currentNode) { 1197 const text = currentNode.textContent.toLowerCase(); 1198 let startPos = 0; 1199 while (startPos < text.length) { 1200 const index = text.indexOf(searchQuery, startPos); 1201 if (index === -1) { 1202 break; 1203 } 1204 1205 const range = new this.win.Range(); 1206 range.setStart(currentNode, index); 1207 range.setEnd(currentNode, index + searchQueryLength); 1208 1209 searchHighlight.add(range); 1210 1211 startPos = index + searchQuery.length; 1212 1213 // We want to scroll the first matching range into view 1214 if (!scrolled) { 1215 // We want to take advantage of nsISelectionController.scrollSelectionIntoView, 1216 // so we need to put the range in the selection. That's fine to do here because 1217 // in this situation the user shouldn't have any text selected 1218 const selection = this.win.getSelection(); 1219 selection.removeAllRanges(); 1220 selection.addRange(range); 1221 1222 const selectionController = this._getSelectionController(); 1223 selectionController.scrollSelectionIntoView( 1224 selectionController.SELECTION_NORMAL, 1225 selectionController.SELECTION_ON, 1226 selectionController.SCROLL_SYNCHRONOUS | 1227 selectionController.SCROLL_VERTICAL_CENTER 1228 ); 1229 selection.removeAllRanges(); 1230 scrolled = true; 1231 } 1232 } 1233 1234 currentNode = treeWalker.nextNode(); 1235 } 1236 1237 // It can happen that we didn't find a Range for a search result (e.g. if the matching 1238 // string is in a cropped attribute). In such case, go back to scroll the container 1239 // into view. 1240 if (!scrolled) { 1241 const container = this.getContainer( 1242 this.inspector.selection.nodeFront, 1243 this.inspector.selection.isSlotted() 1244 ); 1245 scrollIntoViewIfNeeded( 1246 container.editor.elt, 1247 // centered 1248 true, 1249 // smoothScroll 1250 false 1251 ); 1252 } 1253 this.emitForTests("search-results-highlighting-updated"); 1254 } 1255 1256 /** 1257 * Maybe make selected the current node selection's MarkupContainer depending 1258 * on why the current node got selected. 1259 */ 1260 async maybeNavigateToNewSelection() { 1261 const { reason, nodeFront } = this.inspector.selection; 1262 1263 // The list of reasons that should lead to navigating to the node. 1264 const reasonsToNavigate = [ 1265 // If the user picked an element with the element picker. 1266 "picker-node-picked", 1267 // If the user shift-clicked (previewed) an element. 1268 "picker-node-previewed", 1269 // If the user selected an element with the browser context menu. 1270 "browser-context-menu", 1271 // If the user added a new node by clicking in the inspector toolbar. 1272 "node-inserted", 1273 ]; 1274 1275 // If the user performed an action with a keyboard, move keyboard focus to 1276 // the markup tree container. 1277 if (reason && reason.endsWith("-keyboard")) { 1278 this.getContainer(this._rootNode).elt.focus(); 1279 } 1280 1281 if (reasonsToNavigate.includes(reason)) { 1282 // not sure this is necessary 1283 const root = await nodeFront.walkerFront.getRootNode(); 1284 this.getContainer(root).elt.focus(); 1285 this.navigate(this.getContainer(nodeFront)); 1286 } 1287 } 1288 1289 /** 1290 * Create a TreeWalker to find the next/previous 1291 * node for selection. 1292 */ 1293 _selectionWalker(start) { 1294 const walker = this.doc.createTreeWalker( 1295 start || this._elt, 1296 nodeFilterConstants.SHOW_ELEMENT, 1297 function (element) { 1298 if ( 1299 element.container && 1300 element.container.elt === element && 1301 element.container.visible 1302 ) { 1303 return nodeFilterConstants.FILTER_ACCEPT; 1304 } 1305 return nodeFilterConstants.FILTER_SKIP; 1306 } 1307 ); 1308 walker.currentNode = this._selectedContainer.elt; 1309 return walker; 1310 } 1311 1312 _onCopy(evt) { 1313 // Ignore copy events from editors 1314 if (this.isInputOrTextareaOrInCodeMirrorEditor(evt.target)) { 1315 return; 1316 } 1317 1318 const selection = this.inspector.selection; 1319 if (selection.isNode()) { 1320 this.copyOuterHTML(); 1321 } 1322 evt.stopPropagation(); 1323 evt.preventDefault(); 1324 } 1325 1326 /** 1327 * Copy the outerHTML of the selected Node to the clipboard. 1328 */ 1329 copyOuterHTML() { 1330 if (!this.inspector.selection.isNode()) { 1331 return; 1332 } 1333 const node = this.inspector.selection.nodeFront; 1334 1335 switch (node.nodeType) { 1336 case nodeConstants.ELEMENT_NODE: 1337 copyLongHTMLString(node.walkerFront.outerHTML(node)); 1338 break; 1339 case nodeConstants.COMMENT_NODE: 1340 getLongString(node.getNodeValue()).then(comment => { 1341 clipboardHelper.copyString("<!--" + comment + "-->"); 1342 }); 1343 break; 1344 case nodeConstants.DOCUMENT_TYPE_NODE: 1345 clipboardHelper.copyString(node.doctypeString); 1346 break; 1347 } 1348 } 1349 1350 /** 1351 * Copy the innerHTML of the selected Node to the clipboard. 1352 */ 1353 copyInnerHTML() { 1354 const nodeFront = this.inspector.selection.nodeFront; 1355 if (!this.inspector.selection.isNode()) { 1356 return; 1357 } 1358 1359 copyLongHTMLString(nodeFront.walkerFront.innerHTML(nodeFront)); 1360 } 1361 1362 /** 1363 * Given a type and link found in a node's attribute in the markup-view, 1364 * attempt to follow that link (which may result in opening a new tab, the 1365 * style editor or debugger). 1366 */ 1367 followAttributeLink(type, link) { 1368 if (!type || !link) { 1369 return; 1370 } 1371 1372 const nodeFront = this.inspector.selection.nodeFront; 1373 if (type === "uri" || type === "cssresource" || type === "jsresource") { 1374 // Open link in a new tab. 1375 nodeFront.inspectorFront 1376 .resolveRelativeURL(link, this.inspector.selection.nodeFront) 1377 .then(url => { 1378 if (type === "uri") { 1379 openContentLink(url); 1380 } else if (type === "cssresource") { 1381 return this.toolbox.viewGeneratedSourceInStyleEditor(url); 1382 } else if (type === "jsresource") { 1383 return this.toolbox.viewGeneratedSourceInDebugger(url); 1384 } 1385 return null; 1386 }) 1387 .catch(console.error); 1388 } else if (type == "idref") { 1389 // Select the node in the same document. 1390 nodeFront.walkerFront 1391 .getIdrefNode( 1392 nodeFront, 1393 // No need to escape the id, the server getIdrefNode uses getElementById 1394 link 1395 ) 1396 .then(node => { 1397 if (!node) { 1398 this.emitForTests("idref-attribute-link-failed"); 1399 return; 1400 } 1401 this.inspector.selection.setNodeFront(node, { 1402 reason: "markup-attribute-link", 1403 }); 1404 }) 1405 .catch(console.error); 1406 } 1407 } 1408 1409 /** 1410 * Register all key shortcuts. 1411 */ 1412 _initShortcuts() { 1413 const shortcuts = new KeyShortcuts({ 1414 window: this.win, 1415 }); 1416 1417 // Keep a pointer on shortcuts to destroy them when destroying the markup 1418 // view. 1419 this._shortcuts = shortcuts; 1420 1421 this._onShortcut = this._onShortcut.bind(this); 1422 1423 // Process localizable keys 1424 [ 1425 "markupView.hide.key", 1426 "markupView.edit.key", 1427 "markupView.scrollInto.key", 1428 ].forEach(name => { 1429 const key = INSPECTOR_L10N.getStr(name); 1430 shortcuts.on(key, event => this._onShortcut(name, event)); 1431 }); 1432 1433 // Process generic keys: 1434 [ 1435 "Delete", 1436 "Backspace", 1437 "Home", 1438 "Left", 1439 "Right", 1440 "Up", 1441 "Down", 1442 "PageUp", 1443 "PageDown", 1444 "Esc", 1445 "Enter", 1446 "Space", 1447 ].forEach(key => { 1448 shortcuts.on(key, event => this._onShortcut(key, event)); 1449 }); 1450 } 1451 1452 /** 1453 * Key shortcut listener. 1454 */ 1455 _onShortcut(name, event) { 1456 if (this.isInputOrTextareaOrInCodeMirrorEditor(event.target)) { 1457 return; 1458 } 1459 1460 // If the selected element is a button (e.g. `flex` badge), we don't want to highjack 1461 // keyboard activation. 1462 if ( 1463 event.target.closest(":is(button, [role=button])") && 1464 (name === "Enter" || name === "Space") 1465 ) { 1466 return; 1467 } 1468 1469 const handler = shortcutHandlers[name]; 1470 const shouldPropagate = handler(this); 1471 if (shouldPropagate) { 1472 return; 1473 } 1474 1475 event.stopPropagation(); 1476 event.preventDefault(); 1477 } 1478 1479 /** 1480 * Check if a node is used to type text (i.e. an input or textarea, or in a CodeMirror editor) 1481 */ 1482 isInputOrTextareaOrInCodeMirrorEditor(element) { 1483 const name = element.tagName.toLowerCase(); 1484 if (name === "input" || name === "textarea") { 1485 return true; 1486 } 1487 1488 if (element.closest(".cm-editor")) { 1489 return true; 1490 } 1491 1492 return false; 1493 } 1494 1495 /** 1496 * If there's an attribute on the current node that's currently focused, then 1497 * delete this attribute, otherwise delete the node itself. 1498 * 1499 * @param {boolean} moveBackward 1500 * If set to true and if we're deleting the node, focus the previous 1501 * sibling after deletion, otherwise the next one. 1502 */ 1503 deleteNodeOrAttribute(moveBackward) { 1504 const focusedAttribute = this.doc.activeElement 1505 ? this.doc.activeElement.closest(".attreditor") 1506 : null; 1507 if (focusedAttribute) { 1508 // The focused attribute might not be in the current selected container. 1509 const container = focusedAttribute.closest("li.child").container; 1510 container.removeAttribute(focusedAttribute.dataset.attr); 1511 } else { 1512 this.deleteNode(this._selectedContainer.node, moveBackward); 1513 } 1514 } 1515 1516 /** 1517 * Returns a value indicating whether a node can be deleted. 1518 * 1519 * @param {NodeFront} nodeFront 1520 * The node to test for deletion 1521 */ 1522 isDeletable(nodeFront) { 1523 return !( 1524 nodeFront.isDocumentElement || 1525 nodeFront.nodeType == nodeConstants.DOCUMENT_NODE || 1526 nodeFront.nodeType == nodeConstants.DOCUMENT_TYPE_NODE || 1527 nodeFront.nodeType == nodeConstants.DOCUMENT_FRAGMENT_NODE || 1528 nodeFront.isNativeAnonymous 1529 ); 1530 } 1531 1532 /** 1533 * Delete a node from the DOM. 1534 * This is an undoable action. 1535 * 1536 * @param {NodeFront} node 1537 * The node to remove. 1538 * @param {boolean} moveBackward 1539 * If set to true, focus the previous sibling, otherwise the next one. 1540 */ 1541 deleteNode(node, moveBackward) { 1542 if (!this.isDeletable(node)) { 1543 return; 1544 } 1545 1546 const container = this.getContainer(node); 1547 1548 // Retain the node so we can undo this... 1549 node.walkerFront 1550 .retainNode(node) 1551 .then(() => { 1552 const parent = node.parentNode(); 1553 let nextSibling = null; 1554 this.undo.do( 1555 () => { 1556 node.walkerFront.removeNode(node).then(siblings => { 1557 nextSibling = siblings.nextSibling; 1558 const prevSibling = siblings.previousSibling; 1559 let focusNode = moveBackward ? prevSibling : nextSibling; 1560 1561 // If we can't move as the user wants, we move to the other direction. 1562 // If there is no sibling elements anymore, move to the parent node. 1563 if (!focusNode) { 1564 focusNode = nextSibling || prevSibling || parent; 1565 } 1566 1567 const isNextSiblingText = nextSibling 1568 ? nextSibling.nodeType === nodeConstants.TEXT_NODE 1569 : false; 1570 const isPrevSiblingText = prevSibling 1571 ? prevSibling.nodeType === nodeConstants.TEXT_NODE 1572 : false; 1573 1574 // If the parent had two children and the next or previous sibling 1575 // is a text node, then it now has only a single text node, is about 1576 // to be in-lined; and focus should move to the parent. 1577 if ( 1578 parent.numChildren === 2 && 1579 (isNextSiblingText || isPrevSiblingText) 1580 ) { 1581 focusNode = parent; 1582 } 1583 1584 if (container.selected) { 1585 this.navigate(this.getContainer(focusNode)); 1586 } 1587 }); 1588 }, 1589 () => { 1590 const isValidSibling = nextSibling && !nextSibling.isPseudoElement; 1591 nextSibling = isValidSibling ? nextSibling : null; 1592 node.walkerFront.insertBefore(node, parent, nextSibling); 1593 } 1594 ); 1595 }) 1596 .catch(console.error); 1597 } 1598 1599 /** 1600 * Scroll the node into view. 1601 */ 1602 scrollNodeIntoView() { 1603 if (!this.inspector.selection.supportsScrollIntoView()) { 1604 return; 1605 } 1606 1607 this.inspector.selection.nodeFront 1608 .scrollIntoView() 1609 .then(() => this.emitForTests("node-scrolled-into-view")); 1610 } 1611 1612 async toggleMutationBreakpoint(name) { 1613 if (!this.inspector.selection.isElementNode()) { 1614 return; 1615 } 1616 1617 const toolboxStore = this.inspector.toolbox.store; 1618 const nodeFront = this.inspector.selection.nodeFront; 1619 1620 if (nodeFront.mutationBreakpoints[name]) { 1621 toolboxStore.dispatch(deleteDOMMutationBreakpoint(nodeFront, name)); 1622 } else { 1623 toolboxStore.dispatch(createDOMMutationBreakpoint(nodeFront, name)); 1624 } 1625 } 1626 1627 /** 1628 * If an editable item is focused, select its container. 1629 */ 1630 _onFocus(event) { 1631 let parent = event.target; 1632 while (!parent.container) { 1633 parent = parent.parentNode; 1634 } 1635 if (parent) { 1636 this.navigate(parent.container); 1637 } 1638 } 1639 1640 /** 1641 * Handle a user-requested navigation to a given MarkupContainer, 1642 * updating the inspector's currently-selected node. 1643 * 1644 * @param {MarkupContainer} container 1645 * The container we're navigating to. 1646 */ 1647 navigate(container) { 1648 if (!container) { 1649 return; 1650 } 1651 1652 this._markContainerAsSelected(container, "treepanel"); 1653 } 1654 1655 /** 1656 * Make sure a node is included in the markup tool. 1657 * 1658 * @param {NodeFront} node 1659 * The node in the content document. 1660 * @param {boolean} flashNode 1661 * Whether the newly imported node should be flashed 1662 * @param {boolean} slotted 1663 * Whether we are importing the slotted version of the node. 1664 * @return {MarkupContainer} The MarkupContainer object for this element. 1665 */ 1666 importNode(node, flashNode, slotted) { 1667 if (!node) { 1668 return null; 1669 } 1670 1671 if (this.hasContainer(node, slotted)) { 1672 return this.getContainer(node, slotted); 1673 } 1674 1675 let container; 1676 const { nodeType, isPseudoElement } = node; 1677 if (node === node.walkerFront.rootNode) { 1678 container = new RootContainer(this, node); 1679 this._elt.appendChild(container.elt); 1680 } 1681 if (node === this.walker.rootNode) { 1682 this._rootNode = node; 1683 } else if (slotted) { 1684 container = new SlottedNodeContainer(this, node, this.inspector); 1685 } else if (nodeType == nodeConstants.ELEMENT_NODE && !isPseudoElement) { 1686 container = new MarkupElementContainer(this, node, this.inspector); 1687 } else if ( 1688 nodeType == nodeConstants.COMMENT_NODE || 1689 nodeType == nodeConstants.TEXT_NODE 1690 ) { 1691 container = new MarkupTextContainer(this, node, this.inspector); 1692 } else { 1693 container = new MarkupReadOnlyContainer(this, node, this.inspector); 1694 } 1695 1696 if (flashNode) { 1697 container.flashMutation(); 1698 } 1699 1700 this.setContainer(node, container, slotted); 1701 this._forceUpdateChildren(container); 1702 1703 this.inspector.emit("container-created", container); 1704 1705 return container; 1706 } 1707 1708 async _onResourceAvailable(resources) { 1709 for (const resource of resources) { 1710 if ( 1711 !this.resourceCommand || 1712 resource.resourceType !== this.resourceCommand.TYPES.ROOT_NODE || 1713 resource.isDestroyed() 1714 ) { 1715 // Only handle alive root-node resources 1716 continue; 1717 } 1718 1719 if (resource.targetFront.isTopLevel && resource.isTopLevelDocument) { 1720 // The topmost root node will lead to the destruction and recreation of 1721 // the MarkupView. This is handled by the inspector. 1722 continue; 1723 } 1724 1725 const parentNodeFront = resource.parentNode(); 1726 const container = this.getContainer(parentNodeFront); 1727 if (container) { 1728 // If there is no container for the parentNodeFront, the markup view is 1729 // currently not watching this part of the tree. 1730 this._forceUpdateChildren(container, { 1731 flash: true, 1732 updateLevel: true, 1733 }); 1734 } 1735 } 1736 } 1737 1738 _onTargetAvailable() {} 1739 1740 _onTargetDestroyed({ targetFront, isModeSwitching }) { 1741 // Bug 1776250: We only watch targets in order to update containers which 1742 // might no longer be able to display children hosted in remote processes, 1743 // which corresponds to a Browser Toolbox mode switch. 1744 if (isModeSwitching) { 1745 const container = this.getContainer(targetFront.getParentNodeFront()); 1746 if (container) { 1747 this._forceUpdateChildren(container, { 1748 updateLevel: true, 1749 }); 1750 } 1751 } 1752 } 1753 1754 /** 1755 * Mutation observer used for included nodes. 1756 */ 1757 _onWalkerMutations(mutations) { 1758 for (const mutation of mutations) { 1759 const type = mutation.type; 1760 const target = mutation.target; 1761 1762 const container = this.getContainer(target); 1763 if (!container) { 1764 // Container might not exist if this came from a load event for a node 1765 // we're not viewing. 1766 continue; 1767 } 1768 1769 if ( 1770 type === "attributes" || 1771 type === "characterData" || 1772 type === "customElementDefined" || 1773 type === "events" || 1774 type === "pseudoClassLock" 1775 ) { 1776 container.update(); 1777 } else if ( 1778 type === "childList" || 1779 type === "slotchange" || 1780 type === "shadowRootAttached" 1781 ) { 1782 this._forceUpdateChildren(container, { 1783 flash: true, 1784 updateLevel: true, 1785 }); 1786 } else if (type === "inlineTextChild") { 1787 this._forceUpdateChildren(container, { flash: true }); 1788 container.update(); 1789 } 1790 } 1791 1792 this._waitForChildren().then(() => { 1793 if (this._destroyed) { 1794 // Could not fully update after markup mutations, the markup-view was destroyed 1795 // while waiting for children. Bail out silently. 1796 return; 1797 } 1798 this._flashMutatedNodes(mutations); 1799 this.inspector.emit("markupmutation", mutations); 1800 1801 // Since the htmlEditor is absolutely positioned, a mutation may change 1802 // the location in which it should be shown. 1803 if (this.htmlEditor) { 1804 this.htmlEditor.refresh(); 1805 } 1806 }); 1807 } 1808 1809 /** 1810 * React to display-change and scrollable-change events from the walker. These are 1811 * events that tell us when something of interest changed on a collection of nodes: 1812 * whether their display type changed, or whether they became scrollable. 1813 * 1814 * @param {Array} nodes 1815 * An array of nodeFronts 1816 */ 1817 _onWalkerNodeStatesChanged(nodes) { 1818 for (const node of nodes) { 1819 const container = this.getContainer(node); 1820 if (container) { 1821 container.update(); 1822 } 1823 } 1824 } 1825 1826 /** 1827 * Given a list of mutations returned by the mutation observer, flash the 1828 * corresponding containers to attract attention. 1829 */ 1830 _flashMutatedNodes(mutations) { 1831 const addedOrEditedContainers = new Set(); 1832 const removedContainers = new Set(); 1833 1834 for (const { type, target, added, removed, newValue } of mutations) { 1835 const container = this.getContainer(target); 1836 1837 if (container) { 1838 if (type === "characterData") { 1839 addedOrEditedContainers.add(container); 1840 } else if (type === "attributes" && newValue === null) { 1841 // Removed attributes should flash the entire node. 1842 // New or changed attributes will flash the attribute itself 1843 // in ElementEditor.flashAttribute. 1844 addedOrEditedContainers.add(container); 1845 } else if (type === "childList") { 1846 // If there has been removals, flash the parent 1847 if (removed.length) { 1848 removedContainers.add(container); 1849 } 1850 1851 // If there has been additions, flash the nodes if their associated 1852 // container exist (so if their parent is expanded in the inspector). 1853 added.forEach(node => { 1854 const addedContainer = this.getContainer(node); 1855 if (addedContainer) { 1856 addedOrEditedContainers.add(addedContainer); 1857 1858 // The node may be added as a result of an append, in which case 1859 // it will have been removed from another container first, but in 1860 // these cases we don't want to flash both the removal and the 1861 // addition 1862 removedContainers.delete(container); 1863 } 1864 }); 1865 } 1866 } 1867 } 1868 1869 for (const container of removedContainers) { 1870 container.flashMutation(); 1871 } 1872 for (const container of addedOrEditedContainers) { 1873 container.flashMutation(); 1874 } 1875 } 1876 1877 /** 1878 * Make sure the given node's parents are expanded and the 1879 * node is scrolled on to screen. 1880 * 1881 * @param {NodeFront} nodeFront 1882 * @param {object} options 1883 * @param {boolean} options.centered 1884 * @param {boolean} options.scroll 1885 * @param {boolean} options.slotted 1886 * @param {boolean} options.smoothScroll 1887 * @returns 1888 */ 1889 showNode( 1890 nodeFront, 1891 { centered = true, scroll = true, slotted, smoothScroll = false } = {} 1892 ) { 1893 if (slotted && !this.hasContainer(nodeFront, slotted)) { 1894 throw new Error("Tried to show a slotted node not previously imported"); 1895 } else { 1896 this._ensureNodeImported(nodeFront); 1897 } 1898 1899 return this._waitForChildren() 1900 .then(() => { 1901 if (this._destroyed) { 1902 return Promise.reject("markupview destroyed"); 1903 } 1904 return this._ensureVisible(nodeFront); 1905 }) 1906 .then(() => { 1907 if (!scroll) { 1908 return; 1909 } 1910 1911 const container = this.getContainer(nodeFront, slotted); 1912 scrollIntoViewIfNeeded(container.editor.elt, centered, smoothScroll); 1913 }, this._handleRejectionIfNotDestroyed); 1914 } 1915 1916 _ensureNodeImported(node) { 1917 let parent = node; 1918 1919 this.importNode(node); 1920 1921 while ((parent = this._getParentInTree(parent))) { 1922 this.importNode(parent); 1923 this.expandNode(parent); 1924 } 1925 } 1926 1927 /** 1928 * Expand the container's children. 1929 */ 1930 _expandContainer(container) { 1931 return this._updateChildren(container, { expand: true }).then(() => { 1932 if (this._destroyed) { 1933 // Could not expand the node, the markup-view was destroyed in the meantime. Just 1934 // silently give up. 1935 return; 1936 } 1937 container.setExpanded(true); 1938 }); 1939 } 1940 1941 /** 1942 * Expand the node's children. 1943 */ 1944 expandNode(node) { 1945 const container = this.getContainer(node); 1946 return this._expandContainer(container); 1947 } 1948 1949 /** 1950 * Expand the entire tree beneath a container. 1951 * 1952 * @param {MarkupContainer} container 1953 * The container to expand. 1954 */ 1955 _expandAll(container) { 1956 return this._expandContainer(container) 1957 .then(() => { 1958 let child = container.children.firstChild; 1959 const promises = []; 1960 while (child) { 1961 promises.push(this._expandAll(child.container)); 1962 child = child.nextSibling; 1963 } 1964 return Promise.all(promises); 1965 }) 1966 .catch(console.error); 1967 } 1968 1969 /** 1970 * Expand the entire tree beneath a node. 1971 * 1972 * @param {DOMNode} node 1973 * The node to expand, or null to start from the top. 1974 * @return {Promise} promise that resolves once all children are expanded. 1975 */ 1976 expandAll(node) { 1977 node = node || this._rootNode; 1978 return this._expandAll(this.getContainer(node)); 1979 } 1980 1981 /** 1982 * Collapse the node's children. 1983 */ 1984 collapseNode(node) { 1985 const container = this.getContainer(node); 1986 container.setExpanded(false); 1987 } 1988 1989 _collapseAll(container) { 1990 container.setExpanded(false); 1991 const children = container.getChildContainers() || []; 1992 children.forEach(child => this._collapseAll(child)); 1993 } 1994 1995 /** 1996 * Collapse the entire tree beneath a node. 1997 * 1998 * @param {DOMNode} node 1999 * The node to collapse. 2000 * @return {Promise} promise that resolves once all children are collapsed. 2001 */ 2002 collapseAll(node) { 2003 this._collapseAll(this.getContainer(node)); 2004 2005 // collapseAll is synchronous, return a promise for consistency with expandAll. 2006 return Promise.resolve(); 2007 } 2008 2009 /** 2010 * Returns either the innerHTML or the outerHTML for a remote node. 2011 * 2012 * @param {NodeFront} node 2013 * The NodeFront to get the outerHTML / innerHTML for. 2014 * @param {boolean} isOuter 2015 * If true, makes the function return the outerHTML, 2016 * otherwise the innerHTML. 2017 * @return {Promise} that will be resolved with the outerHTML / innerHTML. 2018 */ 2019 _getNodeHTML(node, isOuter) { 2020 let walkerPromise = null; 2021 2022 if (isOuter) { 2023 walkerPromise = node.walkerFront.outerHTML(node); 2024 } else { 2025 walkerPromise = node.walkerFront.innerHTML(node); 2026 } 2027 2028 return getLongString(walkerPromise); 2029 } 2030 2031 /** 2032 * Retrieve the outerHTML for a remote node. 2033 * 2034 * @param {NodeFront} node 2035 * The NodeFront to get the outerHTML for. 2036 * @return {Promise} that will be resolved with the outerHTML. 2037 */ 2038 getNodeOuterHTML(node) { 2039 return this._getNodeHTML(node, true); 2040 } 2041 2042 /** 2043 * Retrieve the innerHTML for a remote node. 2044 * 2045 * @param {NodeFront} node 2046 * The NodeFront to get the innerHTML for. 2047 * @return {Promise} that will be resolved with the innerHTML. 2048 */ 2049 getNodeInnerHTML(node) { 2050 return this._getNodeHTML(node); 2051 } 2052 2053 /** 2054 * Listen to mutations, expect a given node to be removed and try and select 2055 * the node that sits at the same place instead. 2056 * This is useful when changing the outerHTML or the tag name so that the 2057 * newly inserted node gets selected instead of the one that just got removed. 2058 */ 2059 reselectOnRemoved(removedNode, reason) { 2060 // Only allow one removed node reselection at a time, so that when there are 2061 // more than 1 request in parallel, the last one wins. 2062 this.cancelReselectOnRemoved(); 2063 2064 // Get the removedNode index in its parent node to reselect the right node. 2065 const isRootElement = ["html", "svg"].includes( 2066 removedNode.tagName.toLowerCase() 2067 ); 2068 const oldContainer = this.getContainer(removedNode); 2069 const parentContainer = this.getContainer(removedNode.parentNode()); 2070 const childIndex = parentContainer 2071 .getChildContainers() 2072 .indexOf(oldContainer); 2073 2074 const onMutations = (this._removedNodeObserver = mutations => { 2075 let isNodeRemovalMutation = false; 2076 for (const mutation of mutations) { 2077 const containsRemovedNode = 2078 mutation.removed && mutation.removed.some(n => n === removedNode); 2079 if ( 2080 mutation.type === "childList" && 2081 (containsRemovedNode || isRootElement) 2082 ) { 2083 isNodeRemovalMutation = true; 2084 break; 2085 } 2086 } 2087 if (!isNodeRemovalMutation) { 2088 return; 2089 } 2090 2091 this.inspector.off("markupmutation", onMutations); 2092 this._removedNodeObserver = null; 2093 2094 // Don't select the new node if the user has already changed the current 2095 // selection. 2096 if ( 2097 this.inspector.selection.nodeFront === parentContainer.node || 2098 (this.inspector.selection.nodeFront === removedNode && isRootElement) 2099 ) { 2100 const childContainers = parentContainer.getChildContainers(); 2101 if (childContainers?.[childIndex]) { 2102 const childContainer = childContainers[childIndex]; 2103 this._markContainerAsSelected(childContainer, reason); 2104 if (childContainer.hasChildren) { 2105 this.expandNode(childContainer.node); 2106 } 2107 this.emit("reselectedonremoved"); 2108 } 2109 } 2110 }); 2111 2112 // Start listening for mutations until we find a childList change that has 2113 // removedNode removed. 2114 this.inspector.on("markupmutation", onMutations); 2115 } 2116 2117 /** 2118 * Make sure to stop listening for node removal markupmutations and not 2119 * reselect the corresponding node when that happens. 2120 * Useful when the outerHTML/tagname edition failed. 2121 */ 2122 cancelReselectOnRemoved() { 2123 if (this._removedNodeObserver) { 2124 this.inspector.off("markupmutation", this._removedNodeObserver); 2125 this._removedNodeObserver = null; 2126 this.emit("canceledreselectonremoved"); 2127 } 2128 } 2129 2130 /** 2131 * Replace the outerHTML of any node displayed in the inspector with 2132 * some other HTML code 2133 * 2134 * @param {NodeFront} node 2135 * Node which outerHTML will be replaced. 2136 * @param {string} newValue 2137 * The new outerHTML to set on the node. 2138 * @param {string} oldValue 2139 * The old outerHTML that will be used if the user undoes the update. 2140 * @return {Promise} that will resolve when the outer HTML has been updated. 2141 */ 2142 updateNodeOuterHTML(node, newValue) { 2143 const container = this.getContainer(node); 2144 if (!container) { 2145 return Promise.reject(); 2146 } 2147 2148 // Changing the outerHTML removes the node which outerHTML was changed. 2149 // Listen to this removal to reselect the right node afterwards. 2150 this.reselectOnRemoved(node, "outerhtml"); 2151 return node.walkerFront.setOuterHTML(node, newValue).catch(() => { 2152 this.cancelReselectOnRemoved(); 2153 }); 2154 } 2155 2156 /** 2157 * Replace the innerHTML of any node displayed in the inspector with 2158 * some other HTML code 2159 * 2160 * @param {Node} node 2161 * node which innerHTML will be replaced. 2162 * @param {string} newValue 2163 * The new innerHTML to set on the node. 2164 * @param {string} oldValue 2165 * The old innerHTML that will be used if the user undoes the update. 2166 * @return {Promise} that will resolve when the inner HTML has been updated. 2167 */ 2168 updateNodeInnerHTML(node, newValue, oldValue) { 2169 const container = this.getContainer(node); 2170 if (!container) { 2171 return Promise.reject(); 2172 } 2173 2174 return new Promise((resolve, reject) => { 2175 container.undo.do( 2176 () => { 2177 node.walkerFront.setInnerHTML(node, newValue).then(resolve, reject); 2178 }, 2179 () => { 2180 node.walkerFront.setInnerHTML(node, oldValue); 2181 } 2182 ); 2183 }); 2184 } 2185 2186 /** 2187 * Insert adjacent HTML to any node displayed in the inspector. 2188 * 2189 * @param {NodeFront} node 2190 * The reference node. 2191 * @param {string} position 2192 * The position as specified for Element.insertAdjacentHTML 2193 * (i.e. "beforeBegin", "afterBegin", "beforeEnd", "afterEnd"). 2194 * @param {string} newValue 2195 * The adjacent HTML. 2196 * @return {Promise} that will resolve when the adjacent HTML has 2197 * been inserted. 2198 */ 2199 insertAdjacentHTMLToNode(node, position, value) { 2200 const container = this.getContainer(node); 2201 if (!container) { 2202 return Promise.reject(); 2203 } 2204 2205 let injectedNodes = []; 2206 2207 return new Promise((resolve, reject) => { 2208 container.undo.do( 2209 () => { 2210 // eslint-disable-next-line no-unsanitized/method 2211 node.walkerFront 2212 .insertAdjacentHTML(node, position, value) 2213 .then(nodeArray => { 2214 injectedNodes = nodeArray.nodes; 2215 return nodeArray; 2216 }) 2217 .then(resolve, reject); 2218 }, 2219 () => { 2220 node.walkerFront.removeNodes(injectedNodes); 2221 } 2222 ); 2223 }); 2224 } 2225 2226 /** 2227 * Open an editor in the UI to allow editing of a node's html. 2228 * 2229 * @param {NodeFront} node 2230 * The NodeFront to edit. 2231 */ 2232 beginEditingHTML(node) { 2233 // We use outer html for elements, but inner html for fragments. 2234 const isOuter = node.nodeType == nodeConstants.ELEMENT_NODE; 2235 const html = isOuter 2236 ? this.getNodeOuterHTML(node) 2237 : this.getNodeInnerHTML(node); 2238 html.then(oldValue => { 2239 const container = this.getContainer(node); 2240 if (!container) { 2241 return; 2242 } 2243 // Load load and create HTML Editor as it is rarely used and fetch complex deps 2244 if (!this.htmlEditor) { 2245 const HTMLEditor = require("resource://devtools/client/inspector/markup/views/html-editor.js"); 2246 this.htmlEditor = new HTMLEditor(this.doc); 2247 } 2248 this.htmlEditor.show(container.tagLine, oldValue); 2249 const start = this.telemetry.msSystemNow(); 2250 this.htmlEditor.once("popuphidden", (commit, value) => { 2251 // Need to focus the <html> element instead of the frame / window 2252 // in order to give keyboard focus back to doc (from editor). 2253 this.doc.documentElement.focus(); 2254 2255 if (commit) { 2256 if (isOuter) { 2257 this.updateNodeOuterHTML(node, value, oldValue); 2258 } else { 2259 this.updateNodeInnerHTML(node, value, oldValue); 2260 } 2261 } 2262 2263 const end = this.telemetry.msSystemNow(); 2264 this.telemetry.recordEvent("edit_html", "inspector", null, { 2265 made_changes: commit, 2266 time_open: end - start, 2267 }); 2268 }); 2269 2270 this.emit("begin-editing"); 2271 }); 2272 } 2273 2274 /** 2275 * Expand or collapse the given node. 2276 * 2277 * @param {NodeFront} node 2278 * The NodeFront to update. 2279 * @param {boolean} expanded 2280 * Whether the node should be expanded/collapsed. 2281 * @param {boolean} applyToDescendants 2282 * Whether all descendants should also be expanded/collapsed 2283 */ 2284 setNodeExpanded(node, expanded, applyToDescendants) { 2285 if (expanded) { 2286 if (applyToDescendants) { 2287 this.expandAll(node); 2288 } else { 2289 this.expandNode(node); 2290 } 2291 } else if (applyToDescendants) { 2292 this.collapseAll(node); 2293 } else { 2294 this.collapseNode(node); 2295 } 2296 } 2297 2298 /** 2299 * Mark the given node selected, and update the inspector.selection 2300 * object's NodeFront to keep consistent state between UI and selection. 2301 * 2302 * @param {NodeFront} node 2303 * The NodeFront to mark as selected. 2304 * @return {boolean} False if the node is already marked as selected, true 2305 * otherwise. 2306 */ 2307 markNodeAsSelected(node) { 2308 const container = this.getContainer(node); 2309 return this._markContainerAsSelected(container); 2310 } 2311 2312 _markContainerAsSelected(container, reason) { 2313 if (!container || this._selectedContainer === container) { 2314 return false; 2315 } 2316 2317 const { node } = container; 2318 2319 // Un-select and remove focus from the previous container. 2320 if (this._selectedContainer) { 2321 this._selectedContainer.selected = false; 2322 this._selectedContainer.clearFocus(); 2323 } 2324 2325 // Select the new container. 2326 this._selectedContainer = container; 2327 if (node) { 2328 this._selectedContainer.selected = true; 2329 } 2330 2331 // Change the current selection if needed. 2332 if (!this._isContainerSelected(this._selectedContainer)) { 2333 const isSlotted = container.isSlotted(); 2334 this.inspector.selection.setNodeFront(node, { reason, isSlotted }); 2335 } 2336 2337 return true; 2338 } 2339 2340 /** 2341 * Make sure that every ancestor of the selection are updated 2342 * and included in the list of visible children. 2343 */ 2344 _ensureVisible(node) { 2345 while (node) { 2346 const container = this.getContainer(node); 2347 const parent = this._getParentInTree(node); 2348 if (!container.elt.parentNode) { 2349 const parentContainer = this.getContainer(parent); 2350 if (parentContainer) { 2351 this._forceUpdateChildren(parentContainer, { expand: true }); 2352 } 2353 } 2354 2355 node = parent; 2356 } 2357 return this._waitForChildren(); 2358 } 2359 2360 /** 2361 * Unmark selected node (no node selected). 2362 */ 2363 unmarkSelectedNode() { 2364 if (this._selectedContainer) { 2365 this._selectedContainer.selected = false; 2366 this._selectedContainer = null; 2367 } 2368 } 2369 2370 /** 2371 * Check if the current selection is a descendent of the container. 2372 * if so, make sure it's among the visible set for the container, 2373 * and set the dirty flag if needed. 2374 * 2375 * @return The node that should be made visible, if any. 2376 */ 2377 _checkSelectionVisible(container) { 2378 let centered = null; 2379 let node = this.inspector.selection.nodeFront; 2380 while (node) { 2381 if (this._getParentInTree(node) === container.node) { 2382 centered = node; 2383 break; 2384 } 2385 node = this._getParentInTree(node); 2386 } 2387 2388 return centered; 2389 } 2390 2391 async _forceUpdateChildren(container, options = {}) { 2392 const { flash, updateLevel, expand } = options; 2393 2394 // Set childrenDirty to true to force fetching new children. 2395 container.childrenDirty = true; 2396 2397 // Update the children to take care of changes in the markup view DOM 2398 await this._updateChildren(container, { expand, flash }); 2399 2400 // The markup view may have been destroyed in the meantime 2401 if (this._destroyed) { 2402 return; 2403 } 2404 2405 if (updateLevel) { 2406 // Update container (and its subtree) DOM tree depth level for 2407 // accessibility where necessary. 2408 container.updateLevel(); 2409 } 2410 } 2411 2412 /** 2413 * Make sure all children of the given container's node are 2414 * imported and attached to the container in the right order. 2415 * 2416 * Children need to be updated only in the following circumstances: 2417 * a) We just imported this node and have never seen its children. 2418 * container.childrenDirty will be set by importNode in this case. 2419 * b) We received a childList mutation on the node. 2420 * container.childrenDirty will be set in that case too. 2421 * c) We have changed the selection, and the path to that selection 2422 * wasn't loaded in a previous children request (because we only 2423 * grab a subset). 2424 * container.childrenDirty should be set in that case too! 2425 * 2426 * @param {MarkupContainer} container 2427 * The markup container whose children need updating 2428 * @param {object} options 2429 * Options are {expand:boolean,flash:boolean} 2430 * @return {Promise} that will be resolved when the children are ready 2431 * (which may be immediately). 2432 */ 2433 _updateChildren(container, options) { 2434 // Slotted containers do not display any children. 2435 if (container.isSlotted()) { 2436 return Promise.resolve(container); 2437 } 2438 2439 const expand = options?.expand; 2440 const flash = options?.flash; 2441 2442 container.hasChildren = container.node.hasChildren; 2443 // Accessibility should either ignore empty children or semantically 2444 // consider them a group. 2445 container.setChildrenRole(); 2446 2447 if (!this._queuedChildUpdates) { 2448 this._queuedChildUpdates = new Map(); 2449 } 2450 2451 if (this._queuedChildUpdates.has(container)) { 2452 return this._queuedChildUpdates.get(container); 2453 } 2454 2455 if (!container.childrenDirty) { 2456 return Promise.resolve(container); 2457 } 2458 2459 // Before bailing out for other conditions, check if the unavailable 2460 // children badge needs updating (Bug 1776250). 2461 if ( 2462 typeof container?.editor?.hasUnavailableChildren == "function" && 2463 container.editor.hasUnavailableChildren() != 2464 container.node.childrenUnavailable 2465 ) { 2466 container.update(); 2467 } 2468 2469 if ( 2470 container.inlineTextChild && 2471 container.inlineTextChild != container.node.inlineTextChild 2472 ) { 2473 // This container was doing double duty as a container for a single 2474 // text child, back that out. 2475 this._containers.delete(container.inlineTextChild); 2476 container.clearInlineTextChild(); 2477 2478 if (container.hasChildren && container.selected) { 2479 container.setExpanded(true); 2480 } 2481 } 2482 2483 if (container.node.inlineTextChild) { 2484 container.setExpanded(false); 2485 // this container will do double duty as the container for the single text child. 2486 container.children.replaceChildren(); 2487 2488 container.setInlineTextChild(container.node.inlineTextChild); 2489 2490 this.setContainer(container.node.inlineTextChild, container); 2491 container.childrenDirty = false; 2492 return Promise.resolve(container); 2493 } 2494 2495 if (!container.hasChildren) { 2496 container.children.replaceChildren(); 2497 container.childrenDirty = false; 2498 container.setExpanded(false); 2499 return Promise.resolve(container); 2500 } 2501 2502 // If we're not expanded (or asked to update anyway), we're done for 2503 // now. Note that this will leave the childrenDirty flag set, so when 2504 // expanded we'll refresh the child list. 2505 if (!(container.expanded || expand)) { 2506 return Promise.resolve(container); 2507 } 2508 2509 // We're going to issue a children request, make sure it includes the 2510 // centered node. 2511 const centered = this._checkSelectionVisible(container); 2512 2513 // Children aren't updated yet, but clear the childrenDirty flag anyway. 2514 // If the dirty flag is re-set while we're fetching we'll need to fetch 2515 // again. 2516 container.childrenDirty = false; 2517 2518 const isShadowHost = container.node.isShadowHost; 2519 const updatePromise = this._getVisibleChildren(container, centered) 2520 .then(children => { 2521 if (!this._containers) { 2522 return Promise.reject("markup view destroyed"); 2523 } 2524 this._queuedChildUpdates.delete(container); 2525 2526 // If children are dirty, we got a change notification for this node 2527 // while the request was in progress, we need to do it again. 2528 if (container.childrenDirty) { 2529 return this._updateChildren(container, { 2530 expand: centered || expand, 2531 }); 2532 } 2533 2534 const fragment = this.doc.createDocumentFragment(); 2535 2536 // Store the focused element before moving elements to the document fragment 2537 const previouslyActiveElement = this.doc.activeElement; 2538 for (const child of children.nodes) { 2539 const slotted = !isShadowHost && child.isDirectShadowHostChild; 2540 const childContainer = this.importNode(child, flash, slotted); 2541 fragment.appendChild(childContainer.elt); 2542 } 2543 2544 container.children.replaceChildren(); 2545 2546 if (!children.hasFirst) { 2547 const topItem = this.buildMoreNodesButtonMarkup(container); 2548 fragment.insertBefore(topItem, fragment.firstChild); 2549 } 2550 if (!children.hasLast) { 2551 const bottomItem = this.buildMoreNodesButtonMarkup(container); 2552 fragment.appendChild(bottomItem); 2553 } 2554 2555 container.children.appendChild(fragment); 2556 // If previouslyActiveElement was moved to `fragment`, the focus was moved elsewhere, 2557 // so here we set it back (see Bug 1955040) 2558 if (container.children.contains(previouslyActiveElement)) { 2559 previouslyActiveElement.focus({ 2560 // don't scroll the item into view, the user might have scrolled away and we 2561 // don't want to disturb them. 2562 preventScroll: true, 2563 }); 2564 } 2565 return container; 2566 }) 2567 .catch(this._handleRejectionIfNotDestroyed); 2568 this._queuedChildUpdates.set(container, updatePromise); 2569 return updatePromise; 2570 } 2571 2572 buildMoreNodesButtonMarkup(container) { 2573 const elt = this.doc.createElement("li"); 2574 elt.classList.add("more-nodes", "devtools-class-comment"); 2575 2576 const label = this.doc.createElement("span"); 2577 label.textContent = INSPECTOR_L10N.getStr("markupView.more.showing"); 2578 elt.appendChild(label); 2579 2580 const button = this.doc.createElement("button"); 2581 button.setAttribute("href", "#"); 2582 const showAllString = PluralForm.get( 2583 container.node.numChildren, 2584 INSPECTOR_L10N.getStr("markupView.more.showAll2") 2585 ); 2586 button.textContent = showAllString.replace( 2587 "#1", 2588 container.node.numChildren 2589 ); 2590 elt.appendChild(button); 2591 2592 button.addEventListener("click", () => { 2593 container.maxChildren = -1; 2594 this._forceUpdateChildren(container); 2595 }); 2596 2597 return elt; 2598 } 2599 2600 _waitForChildren() { 2601 if (!this._queuedChildUpdates) { 2602 return Promise.resolve(undefined); 2603 } 2604 2605 return Promise.all([...this._queuedChildUpdates.values()]); 2606 } 2607 2608 /** 2609 * Return a list of the children to display for this container. 2610 */ 2611 async _getVisibleChildren(container, centered) { 2612 let maxChildren = container.maxChildren || this.maxChildren; 2613 if (maxChildren == -1) { 2614 maxChildren = undefined; 2615 } 2616 2617 // We have to use node's walker and not a top level walker 2618 // as for fission frames, we are going to have multiple walkers 2619 const inspectorFront = 2620 await container.node.targetFront.getFront("inspector"); 2621 return inspectorFront.walker.children(container.node, { 2622 maxNodes: maxChildren, 2623 center: centered, 2624 }); 2625 } 2626 2627 /** 2628 * The parent of a given node as rendered in the markup view is not necessarily 2629 * node.parentNode(). For instance, shadow roots don't have a parentNode, but a host 2630 * element. However they are represented as parent and children in the markup view. 2631 * 2632 * Use this method when you are interested in the parent of a node from the perspective 2633 * of the markup-view tree, and not from the perspective of the actual DOM. 2634 */ 2635 _getParentInTree(node) { 2636 const parent = node.parentOrHost(); 2637 if (!parent) { 2638 return null; 2639 } 2640 2641 // If the parent node belongs to a different target while the node's target is the 2642 // one selected by the user in the iframe picker, we don't want to go further up. 2643 if ( 2644 node.targetFront !== parent.targetFront && 2645 node.targetFront == 2646 this.inspector.commands.targetCommand.selectedTargetFront 2647 ) { 2648 return null; 2649 } 2650 2651 return parent; 2652 } 2653 2654 /** 2655 * Tear down the markup panel. 2656 */ 2657 destroy() { 2658 if (this._destroyed) { 2659 return; 2660 } 2661 2662 this._destroyed = true; 2663 2664 this._hoveredContainer = null; 2665 2666 if (this._contextMenu) { 2667 this._contextMenu.destroy(); 2668 this._contextMenu = null; 2669 } 2670 2671 if (this._eventDetailsTooltip) { 2672 this._eventDetailsTooltip.destroy(); 2673 this._eventDetailsTooltip = null; 2674 } 2675 2676 if (this.htmlEditor) { 2677 this.htmlEditor.destroy(); 2678 this.htmlEditor = null; 2679 } 2680 2681 if (this.imagePreviewTooltip) { 2682 this.imagePreviewTooltip.destroy(); 2683 this.imagePreviewTooltip = null; 2684 } 2685 2686 if (this._undo) { 2687 this._undo.destroy(); 2688 this._undo = null; 2689 } 2690 2691 if (this._shortcuts) { 2692 this._shortcuts.destroy(); 2693 this._shortcuts = null; 2694 } 2695 2696 this.popup.destroy(); 2697 this.popup = null; 2698 this._selectedContainer = null; 2699 2700 this._elt.removeEventListener("blur", this._onBlur, true); 2701 this._elt.removeEventListener("click", this._onMouseClick); 2702 this._elt.removeEventListener("contextmenu", this._onContextMenu); 2703 this._elt.removeEventListener("mousemove", this._onMouseMove); 2704 this._elt.removeEventListener("mouseout", this._onMouseOut); 2705 this._frame.removeEventListener("focus", this._onFocus); 2706 this._unsubscribeFromToolboxStore(); 2707 this.inspector.selection.off("new-node-front", this._onNewSelection); 2708 this.inspector.off( 2709 "search-cleared", 2710 this._updateSearchResultsHighlightingInSelectedNode 2711 ); 2712 this.resourceCommand.unwatchResources( 2713 [this.resourceCommand.TYPES.ROOT_NODE], 2714 { onAvailable: this._onResourceAvailable } 2715 ); 2716 this.targetCommand.unwatchTargets({ 2717 types: [this.targetCommand.TYPES.FRAME], 2718 onAvailable: this._onTargetAvailable, 2719 onDestroyed: this._onTargetDestroyed, 2720 }); 2721 this.inspector.toolbox.nodePicker.off( 2722 "picker-node-hovered", 2723 this._onToolboxPickerHover 2724 ); 2725 this.inspector.toolbox.nodePicker.off( 2726 "picker-node-canceled", 2727 this._onToolboxPickerCanceled 2728 ); 2729 this.inspector.highlighters.off( 2730 "highlighter-shown", 2731 this.onHighlighterShown 2732 ); 2733 this.inspector.highlighters.off( 2734 "highlighter-hidden", 2735 this.onHighlighterHidden 2736 ); 2737 this.inspector.toolbox.off("select", this._onToolboxSelect); 2738 this.win.removeEventListener("copy", this._onCopy); 2739 this.win.removeEventListener("mouseup", this._onMouseUp); 2740 2741 this._walkerEventListener.destroy(); 2742 this._walkerEventListener = null; 2743 2744 this._prefObserver.off( 2745 ATTR_COLLAPSE_ENABLED_PREF, 2746 this._onCollapseAttributesPrefChange 2747 ); 2748 this._prefObserver.off( 2749 ATTR_COLLAPSE_LENGTH_PREF, 2750 this._onCollapseAttributesPrefChange 2751 ); 2752 this._prefObserver.destroy(); 2753 2754 for (const [, container] of this._containers) { 2755 container.destroy(); 2756 } 2757 this._containers = null; 2758 2759 this._elt.innerHTML = ""; 2760 this._elt = null; 2761 2762 this._selectionController = null; 2763 this.controllerWindow = null; 2764 this.doc = null; 2765 this.highlighters = null; 2766 this.walker = null; 2767 this.resourceCommand = null; 2768 this.win = null; 2769 2770 this._lastDropTarget = null; 2771 this._lastDragTarget = null; 2772 } 2773 2774 /** 2775 * Find the closest element with class tag-line. These are used to indicate 2776 * drag and drop targets. 2777 * 2778 * @param {DOMNode} el 2779 * @return {DOMNode} 2780 */ 2781 findClosestDragDropTarget(el) { 2782 return el.classList.contains("tag-line") 2783 ? el 2784 : el.querySelector(".tag-line") || el.closest(".tag-line"); 2785 } 2786 2787 /** 2788 * Takes an element as it's only argument and marks the element 2789 * as the drop target 2790 */ 2791 indicateDropTarget(el) { 2792 if (this._lastDropTarget) { 2793 this._lastDropTarget.classList.remove("drop-target"); 2794 } 2795 2796 if (!el) { 2797 return; 2798 } 2799 2800 const target = this.findClosestDragDropTarget(el); 2801 if (target) { 2802 target.classList.add("drop-target"); 2803 this._lastDropTarget = target; 2804 } 2805 } 2806 2807 /** 2808 * Takes an element to mark it as indicator of dragging target's initial place 2809 */ 2810 indicateDragTarget(el) { 2811 if (this._lastDragTarget) { 2812 this._lastDragTarget.classList.remove("drag-target"); 2813 } 2814 2815 if (!el) { 2816 return; 2817 } 2818 2819 const target = this.findClosestDragDropTarget(el); 2820 if (target) { 2821 target.classList.add("drag-target"); 2822 this._lastDragTarget = target; 2823 } 2824 } 2825 2826 /** 2827 * Used to get the nodes required to modify the markup after dragging the 2828 * element (parent/nextSibling). 2829 */ 2830 get dropTargetNodes() { 2831 const target = this._lastDropTarget; 2832 2833 if (!target) { 2834 return null; 2835 } 2836 2837 let parent, nextSibling; 2838 2839 if ( 2840 target.previousElementSibling && 2841 target.previousElementSibling.nodeName.toLowerCase() === "ul" 2842 ) { 2843 parent = target.parentNode.container.node; 2844 nextSibling = null; 2845 } else { 2846 parent = target.parentNode.container.node.parentNode(); 2847 nextSibling = target.parentNode.container.node; 2848 } 2849 2850 if (nextSibling) { 2851 while ( 2852 nextSibling.displayName === "::marker" || 2853 nextSibling.displayName === "::before" 2854 ) { 2855 nextSibling = 2856 this.getContainer(nextSibling).elt.nextSibling.container.node; 2857 } 2858 if (nextSibling.displayName === "::after") { 2859 parent = target.parentNode.container.node.parentNode(); 2860 nextSibling = null; 2861 } 2862 } 2863 2864 if (parent.nodeType !== nodeConstants.ELEMENT_NODE) { 2865 return null; 2866 } 2867 2868 return { parent, nextSibling }; 2869 } 2870 } 2871 2872 /** 2873 * Copy the content of a longString containing HTML code to the clipboard. 2874 * The string is retrieved, and possibly beautified if the user has the right pref set and 2875 * then placed in the clipboard. 2876 * 2877 * @param {Promise} longStringActorPromise 2878 * The promise expected to resolve a LongStringActor instance 2879 */ 2880 async function copyLongHTMLString(longStringActorPromise) { 2881 let string = await getLongString(longStringActorPromise); 2882 2883 if (Services.prefs.getBoolPref(BEAUTIFY_HTML_ON_COPY_PREF)) { 2884 const { indentUnit, indentWithTabs } = getTabPrefs(); 2885 string = beautify.html(string, { 2886 // eslint-disable-next-line camelcase 2887 preserve_newlines: false, 2888 // eslint-disable-next-line camelcase 2889 indent_size: indentWithTabs ? 1 : indentUnit, 2890 // eslint-disable-next-line camelcase 2891 indent_char: indentWithTabs ? "\t" : " ", 2892 unformatted: [], 2893 }); 2894 } 2895 2896 clipboardHelper.copyString(string); 2897 } 2898 2899 /** 2900 * Map a number from one range to another. 2901 */ 2902 function map(value, oldMin, oldMax, newMin, newMax) { 2903 const ratio = oldMax - oldMin; 2904 if (ratio == 0) { 2905 return value; 2906 } 2907 return newMin + (newMax - newMin) * ((value - oldMin) / ratio); 2908 } 2909 2910 module.exports = MarkupView;