element-editor.js (40609B)
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 TextEditor = require("resource://devtools/client/inspector/markup/views/text-editor.js"); 8 const { truncateString } = require("resource://devtools/shared/string.js"); 9 const { 10 editableField, 11 InplaceEditor, 12 } = require("resource://devtools/client/shared/inplace-editor.js"); 13 const { 14 parseAttribute, 15 ATTRIBUTE_TYPES, 16 } = require("resource://devtools/client/shared/node-attribute-parser.js"); 17 18 loader.lazyRequireGetter( 19 this, 20 [ 21 "flashElementOn", 22 "flashElementOff", 23 "getAutocompleteMaxWidth", 24 "parseAttributeValues", 25 ], 26 "resource://devtools/client/inspector/markup/utils.js", 27 true 28 ); 29 30 const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); 31 const INSPECTOR_L10N = new LocalizationHelper( 32 "devtools/client/locales/inspector.properties" 33 ); 34 35 // Page size for pageup/pagedown 36 const COLLAPSE_DATA_URL_REGEX = /^data.+base64/; 37 const COLLAPSE_DATA_URL_LENGTH = 60; 38 39 // Contains only void (without end tag) HTML elements 40 const HTML_VOID_ELEMENTS = [ 41 "area", 42 "base", 43 "br", 44 "col", 45 "command", 46 "embed", 47 "hr", 48 "img", 49 "input", 50 "keygen", 51 "link", 52 "meta", 53 "param", 54 "source", 55 "track", 56 "wbr", 57 ]; 58 59 // Contains only valid computed display property types of the node to display in the 60 // element markup and their respective title tooltip text. 61 const DISPLAY_TYPES = { 62 flex: INSPECTOR_L10N.getStr("markupView.display.flex.tooltiptext2"), 63 "inline-flex": INSPECTOR_L10N.getStr( 64 "markupView.display.inlineFlex.tooltiptext2" 65 ), 66 grid: INSPECTOR_L10N.getStr("markupView.display.grid.tooltiptext2"), 67 "inline-grid": INSPECTOR_L10N.getStr( 68 "markupView.display.inlineGrid.tooltiptext2" 69 ), 70 subgrid: INSPECTOR_L10N.getStr("markupView.display.subgrid.tooltiptiptext"), 71 "flow-root": INSPECTOR_L10N.getStr("markupView.display.flowRoot.tooltiptext"), 72 contents: INSPECTOR_L10N.getStr("markupView.display.contents.tooltiptext2"), 73 }; 74 75 /** 76 * Creates an editor for an Element node. 77 */ 78 class ElementEditor { 79 /** 80 * @param {MarkupContainer} container 81 * The container owning this editor. 82 * @param {NodeFront} node 83 * The NodeFront being edited. 84 */ 85 constructor(container, node) { 86 this.container = container; 87 this.node = node; 88 this.markup = this.container.markup; 89 this.doc = this.markup.doc; 90 this.inspector = this.markup.inspector; 91 this.highlighters = this.markup.highlighters; 92 this._cssProperties = this.inspector.cssProperties; 93 94 this.isOverflowDebuggingEnabled = Services.prefs.getBoolPref( 95 "devtools.overflow.debugging.enabled" 96 ); 97 98 // If this is a scrollable element, this specifies whether or not its overflow causing 99 // elements are highlighted. Otherwise, it is null if the element is not scrollable. 100 this.highlightingOverflowCausingElements = this.node.isScrollable 101 ? false 102 : null; 103 104 this.attrElements = new Map(); 105 this.animationTimers = {}; 106 107 this.elt = null; 108 this.tag = null; 109 this.closeTag = null; 110 this.attrList = null; 111 this.newAttr = null; 112 this.closeElt = null; 113 114 this.onCustomBadgeClick = this.onCustomBadgeClick.bind(this); 115 this.onDisplayBadgeClick = this.onDisplayBadgeClick.bind(this); 116 this.onScrollableBadgeClick = this.onScrollableBadgeClick.bind(this); 117 this.onExpandBadgeClick = this.onExpandBadgeClick.bind(this); 118 this.onTagEdit = this.onTagEdit.bind(this); 119 120 this.buildMarkup(); 121 122 const isVoidElement = HTML_VOID_ELEMENTS.includes(this.node.displayName); 123 if (node.isInHTMLDocument && isVoidElement) { 124 this.elt.classList.add("void-element"); 125 } 126 127 this.update(); 128 this.initialized = true; 129 } 130 buildMarkup() { 131 this.elt = this.doc.createElement("span"); 132 this.elt.classList.add("editor"); 133 134 this.renderOpenTag(); 135 this.renderEventBadge(); 136 this.renderCloseTag(); 137 138 // Make the tag name editable (unless this is a remote node or 139 // a document element) 140 if (!this.node.isDocumentElement) { 141 // Make the tag optionally tabbable but not by default. 142 this.tag.setAttribute("tabindex", "-1"); 143 editableField({ 144 element: this.tag, 145 multiline: true, 146 maxWidth: () => getAutocompleteMaxWidth(this.tag, this.container.elt), 147 trigger: "dblclick", 148 stopOnReturn: true, 149 done: this.onTagEdit, 150 cssProperties: this._cssProperties, 151 }); 152 } 153 } 154 155 renderOpenTag() { 156 const open = this.doc.createElement("span"); 157 open.classList.add("open"); 158 open.appendChild(this.doc.createTextNode("<")); 159 this.elt.appendChild(open); 160 161 this.tag = this.doc.createElement("span"); 162 this.tag.classList.add("tag", "force-color-on-flash"); 163 this.tag.setAttribute("tabindex", "-1"); 164 this.tag.textContent = this.node.displayName; 165 open.appendChild(this.tag); 166 167 this.renderAttributes(open); 168 this.renderNewAttributeEditor(open); 169 170 const closingBracket = this.doc.createElement("span"); 171 closingBracket.classList.add("closing-bracket"); 172 closingBracket.textContent = ">"; 173 open.appendChild(closingBracket); 174 } 175 176 renderAttributes(containerEl) { 177 this.attrList = this.doc.createElement("span"); 178 containerEl.appendChild(this.attrList); 179 } 180 181 renderNewAttributeEditor(containerEl) { 182 this.newAttr = this.doc.createElement("span"); 183 this.newAttr.classList.add("newattr"); 184 this.newAttr.setAttribute("tabindex", "-1"); 185 this.newAttr.setAttribute( 186 "aria-label", 187 INSPECTOR_L10N.getStr("markupView.newAttribute.label") 188 ); 189 containerEl.appendChild(this.newAttr); 190 191 // Make the new attribute space editable. 192 this.newAttr.editMode = editableField({ 193 element: this.newAttr, 194 multiline: true, 195 inputClass: "newattr-input", 196 maxWidth: () => getAutocompleteMaxWidth(this.newAttr, this.container.elt), 197 trigger: "dblclick", 198 stopOnReturn: true, 199 contentType: InplaceEditor.CONTENT_TYPES.CSS_MIXED, 200 popup: this.markup.popup, 201 done: (val, commit) => { 202 if (!commit) { 203 return; 204 } 205 206 const doMods = this._startModifyingAttributes(); 207 const undoMods = this._startModifyingAttributes(); 208 this._applyAttributes(val, null, doMods, undoMods); 209 this.container.undo.do( 210 () => { 211 doMods.apply(); 212 }, 213 function () { 214 undoMods.apply(); 215 } 216 ); 217 }, 218 cssProperties: this._cssProperties, 219 }); 220 } 221 222 renderEventBadge() { 223 this.expandBadge = this.doc.createElement("span"); 224 this.expandBadge.classList.add("markup-expand-badge"); 225 this.expandBadge.addEventListener("click", this.onExpandBadgeClick); 226 this.elt.appendChild(this.expandBadge); 227 } 228 229 renderCloseTag() { 230 const close = this.doc.createElement("span"); 231 close.classList.add("close"); 232 close.appendChild(this.doc.createTextNode("</")); 233 this.elt.appendChild(close); 234 235 this.closeTag = this.doc.createElement("span"); 236 this.closeTag.classList.add("tag", "force-color-on-flash"); 237 this.closeTag.textContent = this.node.displayName; 238 close.appendChild(this.closeTag); 239 240 close.appendChild(this.doc.createTextNode(">")); 241 } 242 243 get displayBadge() { 244 return this._displayBadge; 245 } 246 247 set selected(value) { 248 if (this.textEditor) { 249 this.textEditor.selected = value; 250 } 251 } 252 253 flashAttribute(attrName) { 254 if (this.animationTimers[attrName]) { 255 clearTimeout(this.animationTimers[attrName]); 256 } 257 258 flashElementOn(this.getAttributeElement(attrName), { 259 backgroundClass: "theme-bg-contrast", 260 }); 261 262 this.animationTimers[attrName] = setTimeout(() => { 263 flashElementOff(this.getAttributeElement(attrName), { 264 backgroundClass: "theme-bg-contrast", 265 }); 266 }, this.markup.CONTAINER_FLASHING_DURATION); 267 } 268 269 /** 270 * Returns information about node in the editor. 271 * 272 * @param {DOMNode} node 273 * The node to get information from. 274 * @return {object} An object literal with the following information: 275 * {type: "attribute", name: "rel", value: "index", el: node} 276 */ 277 getInfoAtNode(node) { 278 if (!node) { 279 return null; 280 } 281 282 let type = null; 283 let name = null; 284 let value = null; 285 286 // Attribute 287 const attribute = node.closest(".attreditor"); 288 if (attribute) { 289 type = "attribute"; 290 name = attribute.dataset.attr; 291 value = attribute.dataset.value; 292 } 293 294 return { type, name, value, el: node }; 295 } 296 297 /** 298 * Update the state of the editor from the node. 299 */ 300 update() { 301 const nodeAttributes = this.node.attributes || []; 302 303 // Keep the data model in sync with attributes on the node. 304 const currentAttributes = new Set(nodeAttributes.map(a => a.name)); 305 for (const name of this.attrElements.keys()) { 306 if (!currentAttributes.has(name)) { 307 this.removeAttribute(name); 308 } 309 } 310 311 // Only loop through the current attributes on the node. Missing 312 // attributes have already been removed at this point. 313 for (const attr of nodeAttributes) { 314 const el = this.attrElements.get(attr.name); 315 const valueChanged = el && el.dataset.value !== attr.value; 316 const isEditing = el && el.querySelector(".editable").inplaceEditor; 317 const canSimplyShowEditor = el && (!valueChanged || isEditing); 318 319 if (canSimplyShowEditor) { 320 // Element already exists and doesn't need to be recreated. 321 // Just show it (it's hidden by default). 322 el.style.removeProperty("display"); 323 } else { 324 // Create a new editor, because the value of an existing attribute 325 // has changed. 326 const attribute = this._createAttribute(attr, el); 327 attribute.style.removeProperty("display"); 328 329 // Temporarily flash the attribute to highlight the change. 330 // But not if this is the first time the editor instance has 331 // been created. 332 if (this.initialized) { 333 this.flashAttribute(attr.name); 334 } 335 } 336 } 337 338 this.updateEventBadge(); 339 this.updateDisplayBadge(); 340 this.updateCustomBadge(); 341 this.updateScrollableBadge(); 342 this.updateContainerBadge(); 343 this.updateAnchorBadge(); 344 this.updateTextEditor(); 345 this.updateUnavailableChildren(); 346 this.updateOverflowBadge(); 347 this.updateOverflowHighlight(); 348 } 349 350 updateEventBadge() { 351 const showEventBadge = this.node.hasEventListeners; 352 if (this._eventBadge && !showEventBadge) { 353 this._eventBadge.remove(); 354 this._eventBadge = null; 355 } else if (showEventBadge && !this._eventBadge) { 356 this._createEventBadge(); 357 } 358 } 359 360 _createEventBadge() { 361 this._eventBadge = this.doc.createElement("button"); 362 this._eventBadge.className = "inspector-badge interactive"; 363 this._eventBadge.dataset.event = "true"; 364 this._eventBadge.textContent = "event"; 365 this._eventBadge.title = INSPECTOR_L10N.getStr( 366 "markupView.event.tooltiptext2" 367 ); 368 this._eventBadge.setAttribute("aria-pressed", "false"); 369 // Badges order is [event][display][custom], insert event badge before others. 370 this.elt.insertBefore( 371 this._eventBadge, 372 this._displayBadge || this._customBadge 373 ); 374 this.markup.emit("badge-added-event"); 375 } 376 377 updateScrollableBadge() { 378 if (this.node.isScrollable && !this._scrollableBadge) { 379 this._createScrollableBadge(); 380 } else if (this._scrollableBadge && !this.node.isScrollable) { 381 this._scrollableBadge.remove(); 382 this._scrollableBadge = null; 383 } 384 } 385 386 _createScrollableBadge() { 387 const isInteractive = 388 this.isOverflowDebuggingEnabled && 389 // Document elements cannot have interative scrollable badges since retrieval of their 390 // overflow causing elements is not supported. 391 !this.node.isDocumentElement; 392 393 this._scrollableBadge = this.doc.createElement( 394 isInteractive ? "button" : "div" 395 ); 396 this._scrollableBadge.className = `inspector-badge scrollable-badge ${isInteractive ? "interactive" : ""}`; 397 this._scrollableBadge.dataset.scrollable = "true"; 398 this._scrollableBadge.textContent = INSPECTOR_L10N.getStr( 399 "markupView.scrollableBadge.label" 400 ); 401 this._scrollableBadge.title = INSPECTOR_L10N.getStr( 402 isInteractive 403 ? "markupView.scrollableBadge.interactive.tooltip" 404 : "markupView.scrollableBadge.tooltip" 405 ); 406 407 if (isInteractive) { 408 this._scrollableBadge.addEventListener( 409 "click", 410 this.onScrollableBadgeClick 411 ); 412 this._scrollableBadge.setAttribute("aria-pressed", "false"); 413 } 414 this.elt.insertBefore(this._scrollableBadge, this._customBadge); 415 } 416 417 /** 418 * Update the markup display badge. 419 */ 420 updateDisplayBadge() { 421 const displayType = this.node.displayType; 422 const showDisplayBadge = displayType in DISPLAY_TYPES; 423 424 if (this._displayBadge && !showDisplayBadge) { 425 this._displayBadge.remove(); 426 this._displayBadge = null; 427 } else if (showDisplayBadge) { 428 if (!this._displayBadge) { 429 this._createDisplayBadge(); 430 } 431 432 this._updateDisplayBadgeContent(); 433 } 434 } 435 436 _createDisplayBadge() { 437 this._displayBadge = this.doc.createElement("button"); 438 this._displayBadge.className = "inspector-badge"; 439 this._displayBadge.addEventListener("click", this.onDisplayBadgeClick); 440 // Badges order is [event][display][custom], insert display badge before custom. 441 this.elt.insertBefore(this._displayBadge, this._customBadge); 442 } 443 444 _updateDisplayBadgeContent() { 445 const displayType = this.node.displayType; 446 this._displayBadge.textContent = displayType; 447 this._displayBadge.dataset.display = displayType; 448 this._displayBadge.title = DISPLAY_TYPES[displayType]; 449 450 const isFlex = displayType === "flex" || displayType === "inline-flex"; 451 const isGrid = 452 displayType === "grid" || 453 displayType === "inline-grid" || 454 displayType === "subgrid"; 455 456 const isInteractive = 457 isFlex || 458 (isGrid && this.highlighters.canGridHighlighterToggle(this.node)); 459 460 this._displayBadge.classList.toggle("interactive", isInteractive); 461 462 // Since the badge is a <button>, if it's not interactive we need to indicate 463 // to screen readers that it shouldn't behave like a button. 464 // It's easier to have the badge being a button and "downgrading" it like this, 465 // than having it as a div and adding interactivity. 466 if (isInteractive) { 467 this._displayBadge.removeAttribute("role"); 468 this._displayBadge.setAttribute("aria-pressed", "false"); 469 } else { 470 this._displayBadge.setAttribute("role", "presentation"); 471 this._displayBadge.removeAttribute("aria-pressed"); 472 } 473 } 474 475 updateOverflowBadge() { 476 if (!this.isOverflowDebuggingEnabled) { 477 return; 478 } 479 480 if (this.node.causesOverflow && !this._overflowBadge) { 481 this._createOverflowBadge(); 482 } else if (!this.node.causesOverflow && this._overflowBadge) { 483 this._overflowBadge.remove(); 484 this._overflowBadge = null; 485 } 486 } 487 488 _createOverflowBadge() { 489 this._overflowBadge = this.doc.createElement("div"); 490 this._overflowBadge.className = "inspector-badge overflow-badge"; 491 this._overflowBadge.textContent = INSPECTOR_L10N.getStr( 492 "markupView.overflowBadge.label" 493 ); 494 this._overflowBadge.title = INSPECTOR_L10N.getStr( 495 "markupView.overflowBadge.tooltip" 496 ); 497 this.elt.insertBefore(this._overflowBadge, this._customBadge); 498 } 499 500 /** 501 * Update the markup custom element badge. 502 */ 503 updateCustomBadge() { 504 const showCustomBadge = !!this.node.customElementLocation; 505 if (this._customBadge && !showCustomBadge) { 506 this._customBadge.remove(); 507 this._customBadge = null; 508 } else if (!this._customBadge && showCustomBadge) { 509 this._createCustomBadge(); 510 } 511 } 512 513 _createCustomBadge() { 514 this._customBadge = this.doc.createElement("button"); 515 this._customBadge.className = "inspector-badge interactive"; 516 this._customBadge.dataset.custom = "true"; 517 this._customBadge.textContent = "custom…"; 518 this._customBadge.title = INSPECTOR_L10N.getStr( 519 "markupView.custom.tooltiptext" 520 ); 521 this._customBadge.addEventListener("click", this.onCustomBadgeClick); 522 // Badges order is [event][display][custom], insert custom badge at the end. 523 this.elt.appendChild(this._customBadge); 524 } 525 526 updateContainerBadge() { 527 const showContainerBadge = 528 this.node.containerType === "inline-size" || 529 this.node.containerType === "size"; 530 531 if (this._containerBadge && !showContainerBadge) { 532 this._containerBadge.remove(); 533 this._containerBadge = null; 534 } else if (showContainerBadge && !this._containerBadge) { 535 this._createContainerBadge(); 536 } 537 } 538 539 _createContainerBadge() { 540 this._containerBadge = this.doc.createElement("div"); 541 this._containerBadge.classList.add("inspector-badge"); 542 this._containerBadge.dataset.container = "true"; 543 this._containerBadge.title = `container-type: ${this.node.containerType}`; 544 545 this._containerBadge.append(this.doc.createTextNode("container")); 546 // TODO: Move the logic to handle badges position in a dedicated helper (See Bug 1837921). 547 // Ideally badges order should be [event][display][container][custom] 548 this.elt.insertBefore(this._containerBadge, this._customBadge); 549 this.markup.emit("badge-added-event"); 550 } 551 552 updateAnchorBadge() { 553 const showAnchorBadge = this.node.anchorName?.includes?.("--"); 554 555 if (this._anchorBadge && !showAnchorBadge) { 556 this._anchorBadge.remove(); 557 this._anchorBadge = null; 558 } else if (showAnchorBadge && !this._anchorBadge) { 559 this._createAnchorBadge(); 560 } 561 562 if (this._anchorBadge) { 563 this._anchorBadge.title = `anchor-name: ${this.node.anchorName}`; 564 } 565 } 566 567 _createAnchorBadge() { 568 this._anchorBadge = this.doc.createElement("div"); 569 this._anchorBadge.classList.add("inspector-badge"); 570 this._anchorBadge.dataset.anchor = "true"; 571 572 this._anchorBadge.append(this.doc.createTextNode("anchor")); 573 this.elt.insertBefore(this._anchorBadge, this._containerBadge); 574 } 575 576 /** 577 * If node causes overflow, toggle its overflow highlight if its scrollable ancestor's 578 * scrollable badge is active/inactive. 579 */ 580 async updateOverflowHighlight() { 581 if (!this.isOverflowDebuggingEnabled) { 582 return; 583 } 584 585 let showOverflowHighlight = false; 586 587 if (this.node.causesOverflow) { 588 try { 589 const scrollableAncestor = 590 await this.node.walkerFront.getScrollableAncestorNode(this.node); 591 const markupContainer = scrollableAncestor 592 ? this.markup.getContainer(scrollableAncestor) 593 : null; 594 595 showOverflowHighlight = 596 !!markupContainer?.editor.highlightingOverflowCausingElements; 597 } catch (e) { 598 // This call might fail if called asynchrously after the toolbox is finished 599 // closing. 600 return; 601 } 602 } 603 604 this.setOverflowHighlight(showOverflowHighlight); 605 } 606 607 /** 608 * Show overflow highlight if showOverflowHighlight is true, otherwise hide it. 609 * 610 * @param {boolean} showOverflowHighlight 611 */ 612 setOverflowHighlight(showOverflowHighlight) { 613 this.container.tagState.classList.toggle( 614 "overflow-causing-highlighted", 615 showOverflowHighlight 616 ); 617 } 618 619 /** 620 * Update the inline text editor in case of a single text child node. 621 */ 622 updateTextEditor() { 623 const node = this.node.inlineTextChild; 624 625 if (this.textEditor && this.textEditor.node != node) { 626 this.elt.removeChild(this.textEditor.elt); 627 this.textEditor.destroy(); 628 this.textEditor = null; 629 } 630 631 if (node && !this.textEditor) { 632 // Create a text editor added to this editor. 633 // This editor won't receive an update automatically, so we rely on 634 // child text editors to let us know that we need updating. 635 this.textEditor = new TextEditor(this.container, node, "text"); 636 this.elt.insertBefore( 637 this.textEditor.elt, 638 this.elt.querySelector(".close") 639 ); 640 } 641 642 if (this.textEditor) { 643 this.textEditor.update(); 644 } 645 } 646 647 hasUnavailableChildren() { 648 return !!this.childrenUnavailableElt; 649 } 650 651 /** 652 * Update a special badge displayed for nodes which have children that can't 653 * be inspected by the current session (eg a parent-process only toolbox 654 * inspecting a content browser). 655 */ 656 updateUnavailableChildren() { 657 const childrenUnavailable = this.node.childrenUnavailable; 658 659 if (this.childrenUnavailableElt) { 660 this.elt.removeChild(this.childrenUnavailableElt); 661 this.childrenUnavailableElt = null; 662 } 663 664 if (childrenUnavailable) { 665 this.childrenUnavailableElt = this.doc.createElement("div"); 666 this.childrenUnavailableElt.className = "unavailable-children"; 667 this.childrenUnavailableElt.dataset.label = INSPECTOR_L10N.getStr( 668 "markupView.unavailableChildren.label" 669 ); 670 this.childrenUnavailableElt.title = INSPECTOR_L10N.getStr( 671 "markupView.unavailableChildren.title" 672 ); 673 this.elt.insertBefore( 674 this.childrenUnavailableElt, 675 this.elt.querySelector(".close") 676 ); 677 } 678 } 679 680 _startModifyingAttributes() { 681 return this.node.startModifyingAttributes(); 682 } 683 684 /** 685 * Get the element used for one of the attributes of this element. 686 * 687 * @param {string} attrName 688 * The name of the attribute to get the element for 689 * @return {DOMNode} 690 */ 691 getAttributeElement(attrName) { 692 return this.attrList.querySelector( 693 ".attreditor[data-attr=" + CSS.escape(attrName) + "] .attr-value" 694 ); 695 } 696 697 /** 698 * Remove an attribute from the attrElements object and the DOM. 699 * 700 * @param {string} attrName 701 * The name of the attribute to remove 702 */ 703 removeAttribute(attrName) { 704 const attr = this.attrElements.get(attrName); 705 if (attr) { 706 this.attrElements.delete(attrName); 707 attr.remove(); 708 } 709 } 710 711 /** 712 * Creates and returns the DOM for displaying an attribute with the following DOM 713 * structure: 714 * 715 * dom.span( 716 * { 717 * className: "attreditor", 718 * "data-attr": attribute.name, 719 * "data-value": attribute.value, 720 * }, 721 * " ", 722 * dom.span( 723 * { className: "editable", tabIndex: 0 }, 724 * dom.span({ className: "attr-name" }, attribute.name), 725 * '="', 726 * dom.span({ className: "attr-value" }, attribute.value), 727 * '"' 728 * ) 729 */ 730 _createAttribute(attribute, before = null) { 731 const attr = this.doc.createElement("span"); 732 attr.dataset.attr = attribute.name; 733 attr.dataset.value = attribute.value; 734 attr.classList.add("attreditor"); 735 attr.style.display = "none"; 736 737 attr.appendChild(this.doc.createTextNode(" ")); 738 739 const inner = this.doc.createElement("span"); 740 inner.classList.add("editable"); 741 inner.setAttribute("tabindex", this.container.canFocus ? "0" : "-1"); 742 attr.appendChild(inner); 743 744 const name = this.doc.createElement("span"); 745 name.classList.add("attr-name", "force-color-on-flash"); 746 name.textContent = attribute.name; 747 inner.appendChild(name); 748 749 inner.appendChild(this.doc.createTextNode('="')); 750 751 const val = this.doc.createElement("span"); 752 val.classList.add("attr-value", "force-color-on-flash"); 753 inner.appendChild(val); 754 755 inner.appendChild(this.doc.createTextNode('"')); 756 757 this._setupAttributeEditor(attribute, attr, inner, name, val); 758 759 // Figure out where we should place the attribute. 760 if (attribute.name == "id") { 761 before = this.attrList.firstChild; 762 } else if (attribute.name == "class") { 763 const idNode = this.attrElements.get("id"); 764 before = idNode ? idNode.nextSibling : this.attrList.firstChild; 765 } 766 this.attrList.insertBefore(attr, before); 767 768 this.removeAttribute(attribute.name); 769 this.attrElements.set(attribute.name, attr); 770 771 this._appendAttributeValue(attribute, val); 772 773 return attr; 774 } 775 776 /** 777 * Setup the editable field for the given attribute. 778 * 779 * @param {object} attribute 780 * An object containing the name and value of a DOM attribute. 781 * @param {Element} attrEditorEl 782 * The attribute container <span class="attreditor"> element. 783 * @param {Element} editableEl 784 * The editable <span class="editable"> element that is setup to be 785 * an editable field. 786 * @param {Element} attrNameEl 787 * The attribute name <span class="attr-name"> element. 788 * @param {Element} attrValueEl 789 * The attribute value <span class="attr-value"> element. 790 */ 791 _setupAttributeEditor( 792 attribute, 793 attrEditorEl, 794 editableEl, 795 attrNameEl, 796 attrValueEl 797 ) { 798 // Double quotes need to be handled specially to prevent DOMParser failing. 799 // name="v"a"l"u"e" when editing -> name='v"a"l"u"e"' 800 // name="v'a"l'u"e" when editing -> name="v'a"l'u"e" 801 let editValueDisplayed = attribute.value || ""; 802 const hasDoubleQuote = editValueDisplayed.includes('"'); 803 const hasSingleQuote = editValueDisplayed.includes("'"); 804 let initial = attribute.name + '="' + editValueDisplayed + '"'; 805 806 // Can't just wrap value with ' since the value contains both " and '. 807 if (hasDoubleQuote && hasSingleQuote) { 808 editValueDisplayed = editValueDisplayed.replace(/\"/g, """); 809 initial = attribute.name + '="' + editValueDisplayed + '"'; 810 } 811 812 // Wrap with ' since there are no single quotes in the attribute value. 813 if (hasDoubleQuote && !hasSingleQuote) { 814 initial = attribute.name + "='" + editValueDisplayed + "'"; 815 } 816 817 // Make the attribute editable. 818 attrEditorEl.editMode = editableField({ 819 element: editableEl, 820 trigger: "dblclick", 821 stopOnReturn: true, 822 selectAll: false, 823 initial, 824 multiline: true, 825 maxWidth: () => getAutocompleteMaxWidth(editableEl, this.container.elt), 826 contentType: InplaceEditor.CONTENT_TYPES.CSS_MIXED, 827 popup: this.markup.popup, 828 start: (editor, event) => { 829 // If the editing was started inside the name or value areas, 830 // select accordingly. 831 if (event?.target === attrNameEl) { 832 editor.input.setSelectionRange(0, attrNameEl.textContent.length); 833 } else if (event?.target.closest(".attr-value") === attrValueEl) { 834 const length = editValueDisplayed.length; 835 const editorLength = editor.input.value.length; 836 const start = editorLength - (length + 1); 837 editor.input.setSelectionRange(start, start + length); 838 } else { 839 editor.input.select(); 840 } 841 }, 842 done: (newValue, commit, direction) => { 843 if (!commit || newValue === initial) { 844 return; 845 } 846 847 const doMods = this._startModifyingAttributes(); 848 const undoMods = this._startModifyingAttributes(); 849 850 // Remove the attribute stored in this editor and re-add any attributes 851 // parsed out of the input element. Restore original attribute if 852 // parsing fails. 853 this.refocusOnEdit(attribute.name, attrEditorEl, direction); 854 this._saveAttribute(attribute.name, undoMods); 855 doMods.removeAttribute(attribute.name); 856 this._applyAttributes(newValue, attrEditorEl, doMods, undoMods); 857 this.container.undo.do( 858 () => { 859 doMods.apply(); 860 }, 861 () => { 862 undoMods.apply(); 863 } 864 ); 865 }, 866 cssProperties: this._cssProperties, 867 }); 868 } 869 870 /** 871 * Appends the attribute value to the given attribute value <span> element. 872 * 873 * @param {object} attribute 874 * An object containing the name and value of a DOM attribute. 875 * @param {Element} attributeValueEl 876 * The attribute value <span class="attr-value"> element to append 877 * the parsed attribute values to. 878 */ 879 _appendAttributeValue(attribute, attributeValueEl) { 880 // Parse the attribute value to detect whether there are linkable parts in 881 // it (make sure to pass a complete list of existing attributes to the 882 // parseAttribute function, by concatenating attribute, because this could 883 // be a newly added attribute not yet on this.node). 884 const attributes = this.node.attributes.filter( 885 existingAttribute => existingAttribute.name !== attribute.name 886 ); 887 attributes.push(attribute); 888 889 const parsedLinksData = parseAttribute( 890 this.node.namespaceURI, 891 this.node.tagName, 892 attributes, 893 attribute.name, 894 attribute.value 895 ); 896 897 attributeValueEl.innerHTML = ""; 898 899 // Create links in the attribute value, and truncate long attribute values if needed. 900 for (const token of parsedLinksData) { 901 if (token.type === "string" || token.value?.trim() === "") { 902 attributeValueEl.appendChild( 903 this.doc.createTextNode(this._truncateAttributeValue(token.value)) 904 ); 905 } else { 906 const link = this.doc.createElement("span"); 907 link.classList.add("link"); 908 link.setAttribute("data-type", token.type); 909 link.setAttribute("data-link", token.value); 910 link.textContent = this._truncateAttributeValue(token.value); 911 attributeValueEl.append(link); 912 913 // Add a "select node" button when we reference element ids 914 if ( 915 token.type === ATTRIBUTE_TYPES.TYPE_IDREF || 916 token.type === ATTRIBUTE_TYPES.TYPE_IDREF_LIST 917 ) { 918 const button = this.doc.createElement("button"); 919 button.classList.add("select-node"); 920 button.setAttribute( 921 "title", 922 INSPECTOR_L10N.getFormatStr( 923 "inspector.menu.selectElement.label", 924 token.value 925 ) 926 ); 927 link.append(button); 928 } 929 } 930 } 931 } 932 933 /** 934 * Truncates the given attribute value if it is a base64 data URL or the 935 * collapse attributes pref is enabled. 936 * 937 * @param {string} value 938 * Attribute value. 939 * @return {string} truncated attribute value. 940 */ 941 _truncateAttributeValue(value) { 942 if (value && value.match(COLLAPSE_DATA_URL_REGEX)) { 943 return truncateString(value, COLLAPSE_DATA_URL_LENGTH); 944 } 945 946 return this.markup.collapseAttributes 947 ? truncateString(value, this.markup.collapseAttributeLength) 948 : value; 949 } 950 951 /** 952 * Parse a user-entered attribute string and apply the resulting 953 * attributes to the node. This operation is undoable. 954 * 955 * @param {string} value 956 * The user-entered value. 957 * @param {DOMNode} attrNode 958 * The attribute editor that created this 959 * set of attributes, used to place new attributes where the 960 * user put them. 961 */ 962 _applyAttributes(value, attrNode, doMods, undoMods) { 963 const attrs = parseAttributeValues(value, this.doc); 964 for (const attr of attrs) { 965 // Create an attribute editor next to the current attribute if needed. 966 this._createAttribute(attr, attrNode ? attrNode.nextSibling : null); 967 this._saveAttribute(attr.name, undoMods); 968 doMods.setAttribute(attr.name, attr.value); 969 } 970 } 971 972 /** 973 * Saves the current state of the given attribute into an attribute 974 * modification list. 975 */ 976 _saveAttribute(name, undoMods) { 977 const node = this.node; 978 if (node.hasAttribute(name)) { 979 const oldValue = node.getAttribute(name); 980 undoMods.setAttribute(name, oldValue); 981 } else { 982 undoMods.removeAttribute(name); 983 } 984 } 985 986 /** 987 * Listen to mutations, and when the attribute list is regenerated 988 * try to focus on the attribute after the one that's being edited now. 989 * If the attribute order changes, go to the beginning of the attribute list. 990 */ 991 refocusOnEdit(attrName, attrNode, direction) { 992 // Only allow one refocus on attribute change at a time, so when there's 993 // more than 1 request in parallel, the last one wins. 994 if (this._editedAttributeObserver) { 995 this.markup.inspector.off( 996 "markupmutation", 997 this._editedAttributeObserver 998 ); 999 this._editedAttributeObserver = null; 1000 } 1001 1002 const activeElement = this.markup.doc.activeElement; 1003 if (!activeElement || !activeElement.inplaceEditor) { 1004 // The focus was already removed from the current inplace editor, we should not 1005 // refocus the editable attribute. 1006 return; 1007 } 1008 1009 const container = this.markup.getContainer(this.node); 1010 1011 const activeAttrs = [...this.attrList.childNodes].filter( 1012 el => el.style.display != "none" 1013 ); 1014 const attributeIndex = activeAttrs.indexOf(attrNode); 1015 1016 const onMutations = (this._editedAttributeObserver = mutations => { 1017 let isDeletedAttribute = false; 1018 let isNewAttribute = false; 1019 1020 for (const mutation of mutations) { 1021 const inContainer = 1022 this.markup.getContainer(mutation.target) === container; 1023 if (!inContainer) { 1024 continue; 1025 } 1026 1027 const isOriginalAttribute = mutation.attributeName === attrName; 1028 1029 isDeletedAttribute = 1030 isDeletedAttribute || 1031 (isOriginalAttribute && mutation.newValue === null); 1032 isNewAttribute = isNewAttribute || mutation.attributeName !== attrName; 1033 } 1034 1035 const isModifiedOrder = isDeletedAttribute && isNewAttribute; 1036 this._editedAttributeObserver = null; 1037 1038 // "Deleted" attributes are merely hidden, so filter them out. 1039 const visibleAttrs = [...this.attrList.childNodes].filter( 1040 el => el.style.display != "none" 1041 ); 1042 let activeEditor; 1043 if (visibleAttrs.length) { 1044 if (!direction) { 1045 // No direction was given; stay on current attribute. 1046 activeEditor = visibleAttrs[attributeIndex]; 1047 } else if (isModifiedOrder) { 1048 // The attribute was renamed, reordering the existing attributes. 1049 // So let's go to the beginning of the attribute list for consistency. 1050 activeEditor = visibleAttrs[0]; 1051 } else { 1052 let newAttributeIndex; 1053 if (isDeletedAttribute) { 1054 newAttributeIndex = attributeIndex; 1055 } else if (direction == Services.focus.MOVEFOCUS_FORWARD) { 1056 newAttributeIndex = attributeIndex + 1; 1057 } else if (direction == Services.focus.MOVEFOCUS_BACKWARD) { 1058 newAttributeIndex = attributeIndex - 1; 1059 } 1060 1061 // The number of attributes changed (deleted), or we moved through 1062 // the array so check we're still within bounds. 1063 if ( 1064 newAttributeIndex >= 0 && 1065 newAttributeIndex <= visibleAttrs.length - 1 1066 ) { 1067 activeEditor = visibleAttrs[newAttributeIndex]; 1068 } 1069 } 1070 } 1071 1072 // Either we have no attributes left, 1073 // or we just edited the last attribute and want to move on. 1074 if (!activeEditor) { 1075 activeEditor = this.newAttr; 1076 } 1077 1078 // Refocus was triggered by tab or shift-tab. 1079 // Continue in edit mode. 1080 if (direction) { 1081 activeEditor.editMode(); 1082 } else { 1083 // Refocus was triggered by enter. 1084 // Exit edit mode (but restore focus). 1085 const editable = 1086 activeEditor === this.newAttr 1087 ? activeEditor 1088 : activeEditor.querySelector(".editable"); 1089 editable.focus(); 1090 } 1091 1092 this.markup.emit("refocusedonedit"); 1093 }); 1094 1095 // Start listening for mutations until we find an attributes change 1096 // that modifies this attribute. 1097 this.markup.inspector.once("markupmutation", onMutations); 1098 } 1099 1100 /** 1101 * Called when the display badge is clicked. Toggles on the flexbox/grid highlighter for 1102 * the selected node if it is a grid container. 1103 * 1104 * Event handling for highlighter events is delegated up to the Markup view panel. 1105 * When a flexbox/grid highlighter is shown or hidden, the corresponding badge will 1106 * be marked accordingly. See MarkupView.handleHighlighterEvent() 1107 */ 1108 async onDisplayBadgeClick(event) { 1109 event.stopPropagation(); 1110 1111 const target = event.target; 1112 1113 if ( 1114 target.dataset.display === "flex" || 1115 target.dataset.display === "inline-flex" 1116 ) { 1117 await this.highlighters.toggleFlexboxHighlighter(this.node, "markup"); 1118 } 1119 1120 if ( 1121 target.dataset.display === "grid" || 1122 target.dataset.display === "inline-grid" || 1123 target.dataset.display === "subgrid" 1124 ) { 1125 // Don't toggle the grid highlighter if the max number of new grid highlighters 1126 // allowed has been reached. 1127 if (!this.highlighters.canGridHighlighterToggle(this.node)) { 1128 return; 1129 } 1130 1131 await this.highlighters.toggleGridHighlighter(this.node, "markup"); 1132 } 1133 } 1134 1135 async onCustomBadgeClick() { 1136 const { url, line, column } = this.node.customElementLocation; 1137 1138 this.markup.toolbox.viewSourceInDebugger( 1139 url, 1140 line, 1141 column, 1142 null, 1143 "show_custom_element" 1144 ); 1145 } 1146 1147 onExpandBadgeClick() { 1148 this.container.expandContainer(); 1149 } 1150 1151 /** 1152 * Called when the scrollable badge is clicked. Shows the overflow causing elements and 1153 * highlights their container if the scroll badge is active. 1154 */ 1155 async onScrollableBadgeClick() { 1156 this.highlightingOverflowCausingElements = 1157 this._scrollableBadge.classList.toggle("active"); 1158 this._scrollableBadge.setAttribute( 1159 "aria-pressed", 1160 this.highlightingOverflowCausingElements 1161 ); 1162 1163 const { nodes } = await this.node.walkerFront.getOverflowCausingElements( 1164 this.node 1165 ); 1166 1167 for (const node of nodes) { 1168 if (this.highlightingOverflowCausingElements) { 1169 await this.markup.showNode(node); 1170 } 1171 1172 const markupContainer = this.markup.getContainer(node); 1173 1174 if (markupContainer) { 1175 markupContainer.editor.setOverflowHighlight( 1176 this.highlightingOverflowCausingElements 1177 ); 1178 } 1179 } 1180 1181 Glean.devtoolsMarkupScrollableBadge.clicked.add(1); 1182 } 1183 1184 /** 1185 * Called when the tag name editor has is done editing. 1186 */ 1187 async onTagEdit(inputValue, isCommit) { 1188 if (!isCommit) { 1189 return; 1190 } 1191 1192 inputValue = inputValue.trim(); 1193 const spaceIndex = inputValue.indexOf(" "); 1194 const newTagName = 1195 spaceIndex === -1 ? inputValue : inputValue.substring(0, spaceIndex); 1196 1197 const shouldUpdateTagName = 1198 newTagName.toLowerCase() !== this.node.tagName.toLowerCase(); 1199 1200 // If there is content after the tagName, we could have attributes that we need to set 1201 // Changing the tag name removes the node, so set the attributes first, then they 1202 // will be copied in `editTagName` 1203 const newAttributes = 1204 spaceIndex === -1 ? null : inputValue.substring(spaceIndex + 1).trim(); 1205 if (newAttributes?.length) { 1206 const doMods = this._startModifyingAttributes(); 1207 const undoMods = this._startModifyingAttributes(); 1208 this._applyAttributes(newAttributes, null, doMods, undoMods); 1209 // if the tagName will be changed, a new node will be created, and we don't handle 1210 // undo for this, so we can directly set the attributes. 1211 if (shouldUpdateTagName) { 1212 await doMods.apply(); 1213 undoMods.destroy(); 1214 } else { 1215 this.container.undo.do( 1216 () => doMods.apply(), 1217 () => undoMods.apply() 1218 ); 1219 } 1220 } 1221 1222 if (!shouldUpdateTagName) { 1223 return; 1224 } 1225 1226 // Changing the tagName removes the node. Make sure the replacing node gets 1227 // selected afterwards. 1228 this.markup.reselectOnRemoved(this.node, "edittagname"); 1229 try { 1230 await this.node.walkerFront.editTagName(this.node, newTagName); 1231 } catch (e) { 1232 // Failed to edit the tag name, cancel the reselection. 1233 this.markup.cancelReselectOnRemoved(); 1234 } 1235 } 1236 1237 destroy() { 1238 if (this._displayBadge) { 1239 this._displayBadge.removeEventListener("click", this.onDisplayBadgeClick); 1240 } 1241 1242 if (this._customBadge) { 1243 this._customBadge.removeEventListener("click", this.onCustomBadgeClick); 1244 } 1245 1246 if (this._scrollableBadge) { 1247 this._scrollableBadge.removeEventListener( 1248 "click", 1249 this.onScrollableBadgeClick 1250 ); 1251 } 1252 1253 this.expandBadge.removeEventListener("click", this.onExpandBadgeClick); 1254 1255 for (const key in this.animationTimers) { 1256 clearTimeout(this.animationTimers[key]); 1257 } 1258 this.animationTimers = null; 1259 } 1260 } 1261 1262 module.exports = ElementEditor;