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/. */
7 const Services = require("Services");
8 const TextEditor = require("devtools/client/inspector/markup/views/text-editor");
10 getAutocompleteMaxWidth,
14 } = require("devtools/client/inspector/markup/utils");
15 const { truncateString } = require("devtools/shared/inspector/utils");
16 const {editableField, InplaceEditor} =
17 require("devtools/client/shared/inplace-editor");
18 const {parseAttribute} =
19 require("devtools/client/shared/node-attribute-parser");
20 const {getCssProperties} = require("devtools/shared/fronts/css-properties");
22 // Global tooltip inspector
23 const {LocalizationHelper} = require("devtools/shared/l10n");
24 const INSPECTOR_L10N =
25 new LocalizationHelper("devtools/client/locales/inspector.properties");
27 // Page size for pageup/pagedown
28 const COLLAPSE_DATA_URL_REGEX = /^data.+base64/;
29 const COLLAPSE_DATA_URL_LENGTH = 60;
31 // Contains only void (without end tag) HTML elements
32 const HTML_VOID_ELEMENTS = [
33 "area", "base", "br", "col", "command", "embed",
34 "hr", "img", "input", "keygen", "link", "meta", "param", "source",
38 // Contains only valid computed display property types of the node to display in the
39 // element markup and their respective title tooltip text.
40 const DISPLAY_TYPES = {
41 "flex": INSPECTOR_L10N.getStr("markupView.display.flex.tooltiptext"),
42 "inline-flex": INSPECTOR_L10N.getStr("markupView.display.flex.tooltiptext"),
43 "grid": INSPECTOR_L10N.getStr("markupView.display.grid.tooltiptext"),
44 "inline-grid": INSPECTOR_L10N.getStr("markupView.display.inlineGrid.tooltiptext"),
45 "subgrid": INSPECTOR_L10N.getStr("markupView.display.subgrid.tooltiptiptext"),
46 "flow-root": INSPECTOR_L10N.getStr("markupView.display.flowRoot.tooltiptext"),
47 "contents": INSPECTOR_L10N.getStr("markupView.display.contents.tooltiptext2"),
51 * Creates an editor for an Element node.
53 * @param {MarkupContainer} container
54 * The container owning this editor.
55 * @param {Element} node
56 * The node being edited.
58 function ElementEditor(container, node) {
59 this.container = container;
61 this.markup = this.container.markup;
62 this.doc = this.markup.doc;
63 this.inspector = this.markup.inspector;
64 this.highlighters = this.markup.highlighters;
65 this._cssProperties = getCssProperties(this.markup.toolbox);
67 this.attrElements = new Map();
68 this.animationTimers = {};
77 this.onDisplayBadgeClick = this.onDisplayBadgeClick.bind(this);
78 this.onCustomBadgeClick = this.onCustomBadgeClick.bind(this);
79 this.onTagEdit = this.onTagEdit.bind(this);
81 // Create the main editor
84 // Make the tag name editable (unless this is a remote node or
85 // a document element)
86 if (!node.isDocumentElement) {
87 // Make the tag optionally tabbable but not by default.
88 this.tag.setAttribute("tabindex", "-1");
92 maxWidth: () => getAutocompleteMaxWidth(this.tag, this.container.elt),
96 cssProperties: this._cssProperties
100 // Make the new attribute space editable.
101 this.newAttr.editMode = editableField({
102 element: this.newAttr,
104 maxWidth: () => getAutocompleteMaxWidth(this.newAttr, this.container.elt),
107 contentType: InplaceEditor.CONTENT_TYPES.CSS_MIXED,
108 popup: this.markup.popup,
109 done: (val, commit) => {
114 const doMods = this._startModifyingAttributes();
115 const undoMods = this._startModifyingAttributes();
116 this._applyAttributes(val, null, doMods, undoMods);
117 this.container.undo.do(() => {
123 cssProperties: this._cssProperties
126 const displayName = this.node.displayName;
127 this.tag.textContent = displayName;
128 this.closeTag.textContent = displayName;
130 const isVoidElement = HTML_VOID_ELEMENTS.includes(displayName);
131 if (node.isInHTMLDocument && isVoidElement) {
132 this.elt.classList.add("void-element");
136 this.initialized = true;
139 ElementEditor.prototype = {
140 buildMarkup: function() {
141 this.elt = this.doc.createElement("span");
142 this.elt.classList.add("editor");
144 const open = this.doc.createElement("span");
145 open.classList.add("open");
146 open.appendChild(this.doc.createTextNode("<"));
147 this.elt.appendChild(open);
149 this.tag = this.doc.createElement("span");
150 this.tag.classList.add("tag", "theme-fg-color3");
151 this.tag.setAttribute("tabindex", "-1");
152 open.appendChild(this.tag);
154 this.attrList = this.doc.createElement("span");
155 open.appendChild(this.attrList);
157 this.newAttr = this.doc.createElement("span");
158 this.newAttr.classList.add("newattr");
159 this.newAttr.setAttribute("tabindex", "-1");
160 this.newAttr.setAttribute("aria-label",
161 INSPECTOR_L10N.getStr("markupView.newAttribute.label"));
162 open.appendChild(this.newAttr);
164 const closingBracket = this.doc.createElement("span");
165 closingBracket.classList.add("closing-bracket");
166 closingBracket.textContent = ">";
167 open.appendChild(closingBracket);
169 const close = this.doc.createElement("span");
170 close.classList.add("close");
171 close.appendChild(this.doc.createTextNode("</"));
172 this.elt.appendChild(close);
174 this.closeTag = this.doc.createElement("span");
175 this.closeTag.classList.add("tag", "theme-fg-color3");
176 close.appendChild(this.closeTag);
178 close.appendChild(this.doc.createTextNode(">"));
181 set selected(value) {
182 if (this.textEditor) {
183 this.textEditor.selected = value;
187 flashAttribute: function(attrName) {
188 if (this.animationTimers[attrName]) {
189 clearTimeout(this.animationTimers[attrName]);
192 flashElementOn(this.getAttributeElement(attrName));
194 this.animationTimers[attrName] = setTimeout(() => {
195 flashElementOff(this.getAttributeElement(attrName));
196 }, this.markup.CONTAINER_FLASHING_DURATION);
200 * Returns information about node in the editor.
202 * @param {DOMNode} node
203 * The node to get information from.
204 * @return {Object} An object literal with the following information:
205 * {type: "attribute", name: "rel", value: "index", el: node}
207 getInfoAtNode: function(node) {
217 const attribute = node.closest(".attreditor");
220 name = attribute.dataset.attr;
221 value = attribute.dataset.value;
224 return {type, name, value, el: node};
228 * Update the state of the editor from the node.
231 const nodeAttributes = this.node.attributes || [];
233 // Keep the data model in sync with attributes on the node.
234 const currentAttributes = new Set(nodeAttributes.map(a => a.name));
235 for (const name of this.attrElements.keys()) {
236 if (!currentAttributes.has(name)) {
237 this.removeAttribute(name);
241 // Only loop through the current attributes on the node. Missing
242 // attributes have already been removed at this point.
243 for (const attr of nodeAttributes) {
244 const el = this.attrElements.get(attr.name);
245 const valueChanged = el &&
246 el.dataset.value !== attr.value;
247 const isEditing = el && el.querySelector(".editable").inplaceEditor;
248 const canSimplyShowEditor = el && (!valueChanged || isEditing);
250 if (canSimplyShowEditor) {
251 // Element already exists and doesn't need to be recreated.
252 // Just show it (it's hidden by default).
253 el.style.removeProperty("display");
255 // Create a new editor, because the value of an existing attribute
257 const attribute = this._createAttribute(attr, el);
258 attribute.style.removeProperty("display");
260 // Temporarily flash the attribute to highlight the change.
261 // But not if this is the first time the editor instance has
263 if (this.initialized) {
264 this.flashAttribute(attr.name);
269 this.updateEventBadge();
270 this.updateDisplayBadge();
271 this.updateCustomBadge();
272 this.updateTextEditor();
275 updateEventBadge: function() {
276 const showEventBadge = this.node.hasEventListeners;
277 if (this._eventBadge && !showEventBadge) {
278 this._eventBadge.remove();
279 } else if (showEventBadge && !this._eventBadge) {
280 this._createEventBadge();
284 _createEventBadge: function() {
285 this._eventBadge = this.doc.createElement("div");
286 this._eventBadge.classList.add("markup-badge");
287 this._eventBadge.dataset.event = "true";
288 this._eventBadge.textContent = "event";
289 this._eventBadge.title = INSPECTOR_L10N.getStr("markupView.event.tooltiptext");
290 // Badges order is [event][display][custom], insert event badge before others.
291 this.elt.insertBefore(this._eventBadge, this._displayBadge || this._customBadge);
295 * Update the markup display badge.
297 updateDisplayBadge: function() {
298 const showDisplayBadge = this.node.displayType in DISPLAY_TYPES;
299 if (this._displayBadge && !showDisplayBadge) {
300 this._displayBadge.remove();
301 } else if (showDisplayBadge) {
302 if (!this._displayBadge) {
303 this._createDisplayBadge();
305 this._updateDisplayBadgeContent();
309 _createDisplayBadge: function() {
310 this._displayBadge = this.doc.createElement("div");
311 this._displayBadge.classList.add("markup-badge");
312 this._displayBadge.addEventListener("click", this.onDisplayBadgeClick);
313 // Badges order is [event][display][custom], insert display badge before custom.
314 this.elt.insertBefore(this._displayBadge, this._customBadge);
317 _updateDisplayBadgeContent: function() {
318 this._displayBadge.textContent = this.node.displayType;
319 this._displayBadge.dataset.display = this.node.displayType;
320 this._displayBadge.title = DISPLAY_TYPES[this.node.displayType];
321 this._displayBadge.classList.toggle("active",
322 this.highlighters.flexboxHighlighterShown === this.node ||
323 this.highlighters.gridHighlighterShown === this.node);
324 this._displayBadge.classList.toggle("interactive",
325 Services.prefs.getBoolPref("devtools.inspector.flexboxHighlighter.enabled") &&
326 (this.node.displayType === "flex" || this.node.displayType === "inline-flex"));
330 * Update the markup custom element badge.
332 updateCustomBadge: function() {
333 const showCustomBadge = !!this.node.customElementLocation;
334 if (this._customBadge && !showCustomBadge) {
335 this._customBadge.remove();
336 } else if (!this._customBadge && showCustomBadge) {
337 this._createCustomBadge();
341 _createCustomBadge: function() {
342 this._customBadge = this.doc.createElement("div");
343 this._customBadge.classList.add("markup-badge");
344 this._customBadge.dataset.custom = "true";
345 this._customBadge.textContent = "custom…";
346 this._customBadge.title = INSPECTOR_L10N.getStr("markupView.custom.tooltiptext");
347 this._customBadge.addEventListener("click", this.onCustomBadgeClick);
348 // Badges order is [event][display][custom], insert custom badge at the end.
349 this.elt.appendChild(this._customBadge);
353 * Update the inline text editor in case of a single text child node.
355 updateTextEditor: function() {
356 const node = this.node.inlineTextChild;
358 if (this.textEditor && this.textEditor.node != node) {
359 this.elt.removeChild(this.textEditor.elt);
360 this.textEditor = null;
363 if (node && !this.textEditor) {
364 // Create a text editor added to this editor.
365 // This editor won't receive an update automatically, so we rely on
366 // child text editors to let us know that we need updating.
367 this.textEditor = new TextEditor(this.container, node, "text");
368 this.elt.insertBefore(this.textEditor.elt, this.elt.querySelector(".close"));
371 if (this.textEditor) {
372 this.textEditor.update();
376 _startModifyingAttributes: function() {
377 return this.node.startModifyingAttributes();
381 * Get the element used for one of the attributes of this element.
383 * @param {String} attrName
384 * The name of the attribute to get the element for
387 getAttributeElement: function(attrName) {
388 return this.attrList.querySelector(
389 ".attreditor[data-attr=" + CSS.escape(attrName) + "] .attr-value");
393 * Remove an attribute from the attrElements object and the DOM.
395 * @param {String} attrName
396 * The name of the attribute to remove
398 removeAttribute: function(attrName) {
399 const attr = this.attrElements.get(attrName);
401 this.attrElements.delete(attrName);
406 _createAttribute: function(attribute, before = null) {
407 const attr = this.doc.createElement("span");
408 attr.dataset.attr = attribute.name;
409 attr.dataset.value = attribute.value;
410 attr.classList.add("attreditor");
411 attr.style.display = "none";
413 attr.appendChild(this.doc.createTextNode(" "));
415 const inner = this.doc.createElement("span");
416 inner.classList.add("editable");
417 inner.setAttribute("tabindex", this.container.canFocus ? "0" : "-1");
418 attr.appendChild(inner);
420 const name = this.doc.createElement("span");
421 name.classList.add("attr-name");
422 name.classList.add("theme-fg-color2");
423 inner.appendChild(name);
425 inner.appendChild(this.doc.createTextNode('="'));
427 const val = this.doc.createElement("span");
428 val.classList.add("attr-value");
429 val.classList.add("theme-fg-color4");
430 inner.appendChild(val);
432 inner.appendChild(this.doc.createTextNode('"'));
434 // Double quotes need to be handled specially to prevent DOMParser failing.
435 // name="v"a"l"u"e" when editing -> name='v"a"l"u"e"'
436 // name="v'a"l'u"e" when editing -> name="v'a"l'u"e"
437 let editValueDisplayed = attribute.value || "";
438 const hasDoubleQuote = editValueDisplayed.includes('"');
439 const hasSingleQuote = editValueDisplayed.includes("'");
440 let initial = attribute.name + '="' + editValueDisplayed + '"';
442 // Can't just wrap value with ' since the value contains both " and '.
443 if (hasDoubleQuote && hasSingleQuote) {
444 editValueDisplayed = editValueDisplayed.replace(/\"/g, """);
445 initial = attribute.name + '="' + editValueDisplayed + '"';
448 // Wrap with ' since there are no single quotes in the attribute value.
449 if (hasDoubleQuote && !hasSingleQuote) {
450 initial = attribute.name + "='" + editValueDisplayed + "'";
453 // Make the attribute editable.
454 attr.editMode = editableField({
461 maxWidth: () => getAutocompleteMaxWidth(inner, this.container.elt),
462 contentType: InplaceEditor.CONTENT_TYPES.CSS_MIXED,
463 popup: this.markup.popup,
464 start: (editor, event) => {
465 // If the editing was started inside the name or value areas,
466 // select accordingly.
467 if (event && event.target === name) {
468 editor.input.setSelectionRange(0, name.textContent.length);
469 } else if (event && event.target.closest(".attr-value") === val) {
470 const length = editValueDisplayed.length;
471 const editorLength = editor.input.value.length;
472 const start = editorLength - (length + 1);
473 editor.input.setSelectionRange(start, start + length);
475 editor.input.select();
478 done: (newValue, commit, direction) => {
479 if (!commit || newValue === initial) {
483 const doMods = this._startModifyingAttributes();
484 const undoMods = this._startModifyingAttributes();
486 // Remove the attribute stored in this editor and re-add any attributes
487 // parsed out of the input element. Restore original attribute if
489 this.refocusOnEdit(attribute.name, attr, direction);
490 this._saveAttribute(attribute.name, undoMods);
491 doMods.removeAttribute(attribute.name);
492 this._applyAttributes(newValue, attr, doMods, undoMods);
493 this.container.undo.do(() => {
499 cssProperties: this._cssProperties
502 // Figure out where we should place the attribute.
503 if (attribute.name == "id") {
504 before = this.attrList.firstChild;
505 } else if (attribute.name == "class") {
506 const idNode = this.attrElements.get("id");
507 before = idNode ? idNode.nextSibling : this.attrList.firstChild;
509 this.attrList.insertBefore(attr, before);
511 this.removeAttribute(attribute.name);
512 this.attrElements.set(attribute.name, attr);
514 // Parse the attribute value to detect whether there are linkable parts in
515 // it (make sure to pass a complete list of existing attributes to the
516 // parseAttribute function, by concatenating attribute, because this could
517 // be a newly added attribute not yet on this.node).
518 const attributes = this.node.attributes.filter(existingAttribute => {
519 return existingAttribute.name !== attribute.name;
521 attributes.push(attribute);
522 const parsedLinksData = parseAttribute(this.node.namespaceURI,
523 this.node.tagName, attributes, attribute.name);
525 // Create links in the attribute value, and collapse long attributes if
527 const collapse = value => {
528 if (value && value.match(COLLAPSE_DATA_URL_REGEX)) {
529 return truncateString(value, COLLAPSE_DATA_URL_LENGTH);
531 return this.markup.collapseAttributes
532 ? truncateString(value, this.markup.collapseAttributeLength)
537 for (const token of parsedLinksData) {
538 if (token.type === "string") {
539 val.appendChild(this.doc.createTextNode(collapse(token.value)));
541 const link = this.doc.createElement("span");
542 link.classList.add("link");
543 link.setAttribute("data-type", token.type);
544 link.setAttribute("data-link", token.value);
545 link.textContent = collapse(token.value);
546 val.appendChild(link);
550 name.textContent = attribute.name;
556 * Parse a user-entered attribute string and apply the resulting
557 * attributes to the node. This operation is undoable.
559 * @param {String} value
560 * The user-entered value.
561 * @param {DOMNode} attrNode
562 * The attribute editor that created this
563 * set of attributes, used to place new attributes where the
566 _applyAttributes: function(value, attrNode, doMods, undoMods) {
567 const attrs = parseAttributeValues(value, this.doc);
568 for (const attr of attrs) {
569 // Create an attribute editor next to the current attribute if needed.
570 this._createAttribute(attr, attrNode ? attrNode.nextSibling : null);
571 this._saveAttribute(attr.name, undoMods);
572 doMods.setAttribute(attr.name, attr.value);
577 * Saves the current state of the given attribute into an attribute
580 _saveAttribute: function(name, undoMods) {
581 const node = this.node;
582 if (node.hasAttribute(name)) {
583 const oldValue = node.getAttribute(name);
584 undoMods.setAttribute(name, oldValue);
586 undoMods.removeAttribute(name);
591 * Listen to mutations, and when the attribute list is regenerated
592 * try to focus on the attribute after the one that's being edited now.
593 * If the attribute order changes, go to the beginning of the attribute list.
595 refocusOnEdit: function(attrName, attrNode, direction) {
596 // Only allow one refocus on attribute change at a time, so when there's
597 // more than 1 request in parallel, the last one wins.
598 if (this._editedAttributeObserver) {
599 this.markup.inspector.off("markupmutation", this._editedAttributeObserver);
600 this._editedAttributeObserver = null;
603 const activeElement = this.markup.doc.activeElement;
604 if (!activeElement || !activeElement.inplaceEditor) {
605 // The focus was already removed from the current inplace editor, we should not
606 // refocus the editable attribute.
610 const container = this.markup.getContainer(this.node);
612 const activeAttrs = [...this.attrList.childNodes]
613 .filter(el => el.style.display != "none");
614 const attributeIndex = activeAttrs.indexOf(attrNode);
616 const onMutations = this._editedAttributeObserver = mutations => {
617 let isDeletedAttribute = false;
618 let isNewAttribute = false;
620 for (const mutation of mutations) {
622 this.markup.getContainer(mutation.target) === container;
627 const isOriginalAttribute = mutation.attributeName === attrName;
629 isDeletedAttribute = isDeletedAttribute || isOriginalAttribute &&
630 mutation.newValue === null;
631 isNewAttribute = isNewAttribute || mutation.attributeName !== attrName;
634 const isModifiedOrder = isDeletedAttribute && isNewAttribute;
635 this._editedAttributeObserver = null;
637 // "Deleted" attributes are merely hidden, so filter them out.
638 const visibleAttrs = [...this.attrList.childNodes]
639 .filter(el => el.style.display != "none");
641 if (visibleAttrs.length > 0) {
643 // No direction was given; stay on current attribute.
644 activeEditor = visibleAttrs[attributeIndex];
645 } else if (isModifiedOrder) {
646 // The attribute was renamed, reordering the existing attributes.
647 // So let's go to the beginning of the attribute list for consistency.
648 activeEditor = visibleAttrs[0];
650 let newAttributeIndex;
651 if (isDeletedAttribute) {
652 newAttributeIndex = attributeIndex;
653 } else if (direction == Services.focus.MOVEFOCUS_FORWARD) {
654 newAttributeIndex = attributeIndex + 1;
655 } else if (direction == Services.focus.MOVEFOCUS_BACKWARD) {
656 newAttributeIndex = attributeIndex - 1;
659 // The number of attributes changed (deleted), or we moved through
660 // the array so check we're still within bounds.
661 if (newAttributeIndex >= 0 &&
662 newAttributeIndex <= visibleAttrs.length - 1) {
663 activeEditor = visibleAttrs[newAttributeIndex];
668 // Either we have no attributes left,
669 // or we just edited the last attribute and want to move on.
671 activeEditor = this.newAttr;
674 // Refocus was triggered by tab or shift-tab.
675 // Continue in edit mode.
677 activeEditor.editMode();
679 // Refocus was triggered by enter.
680 // Exit edit mode (but restore focus).
681 const editable = activeEditor === this.newAttr ?
682 activeEditor : activeEditor.querySelector(".editable");
686 this.markup.emit("refocusedonedit");
689 // Start listening for mutations until we find an attributes change
690 // that modifies this attribute.
691 this.markup.inspector.once("markupmutation", onMutations);
695 * Called when the display badge is clicked. Toggles on the grid highlighter for the
696 * selected node if it is a grid container.
698 onDisplayBadgeClick: function(event) {
699 event.stopPropagation();
701 const target = event.target;
703 if (Services.prefs.getBoolPref("devtools.inspector.flexboxHighlighter.enabled") &&
704 (target.dataset.display === "flex" || target.dataset.display === "inline-flex")) {
705 this._displayBadge.classList.add("active");
706 this.highlighters.toggleFlexboxHighlighter(this.inspector.selection.nodeFront,
710 if (target.dataset.display === "grid" || target.dataset.display === "inline-grid") {
711 this._displayBadge.classList.add("active");
712 this.highlighters.toggleGridHighlighter(this.inspector.selection.nodeFront,
717 onCustomBadgeClick: function() {
718 const { url, line } = this.node.customElementLocation;
719 this.markup.toolbox.viewSourceInDebugger(url, line, "show_custom_element");
723 * Called when the tag name editor has is done editing.
725 onTagEdit: function(newTagName, isCommit) {
727 newTagName.toLowerCase() === this.node.tagName.toLowerCase() ||
728 !("editTagName" in this.markup.walker)) {
732 // Changing the tagName removes the node. Make sure the replacing node gets
733 // selected afterwards.
734 this.markup.reselectOnRemoved(this.node, "edittagname");
735 this.markup.walker.editTagName(this.node, newTagName).catch(() => {
736 // Failed to edit the tag name, cancel the reselection.
737 this.markup.cancelReselectOnRemoved();
741 destroy: function() {
742 if (this._displayBadge) {
743 this._displayBadge.removeEventListener("click", this.onDisplayBadgeClick);
745 if (this._customBadge) {
746 this._customBadge.removeEventListener("click", this.onCustomBadgeClick);
749 for (const key in this.animationTimers) {
750 clearTimeout(this.animationTimers[key]);
752 this.animationTimers = null;
756 module.exports = ElementEditor;