/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ "use strict"; const {Task} = require("devtools/shared/task"); const EventEmitter = require("devtools/shared/event-emitter"); const {createNode} = require("devtools/client/animationinspector/utils"); const { LocalizationHelper } = require("devtools/shared/l10n"); const STRINGS_URI = "devtools/client/locales/inspector.properties"; const L10N = new LocalizationHelper(STRINGS_URI); /** * UI component responsible for displaying a preview of a dom node. * @param {InspectorPanel} inspector Requires a reference to the inspector-panel * to highlight and select the node, as well as refresh it when there are * mutations. * @param {Object} options Supported properties are: * - compact {Boolean} Defaults to false. * By default, nodes are previewed like * If true, nodes will be previewed like tag#id.class instead. */ function DomNodePreview(inspector, options = {}) { this.inspector = inspector; this.options = options; this.onPreviewMouseOver = this.onPreviewMouseOver.bind(this); this.onPreviewMouseOut = this.onPreviewMouseOut.bind(this); this.onSelectElClick = this.onSelectElClick.bind(this); this.onMarkupMutations = this.onMarkupMutations.bind(this); this.onHighlightElClick = this.onHighlightElClick.bind(this); this.onHighlighterLocked = this.onHighlighterLocked.bind(this); EventEmitter.decorate(this); } exports.DomNodePreview = DomNodePreview; DomNodePreview.prototype = { init: function (containerEl) { let document = containerEl.ownerDocument; // Init the markup for displaying the target node. this.el = createNode({ parent: containerEl, attributes: { "class": "animation-target" } }); // Icon to select the node in the inspector. this.highlightNodeEl = createNode({ parent: this.el, nodeType: "span", attributes: { "class": "node-highlighter", "title": L10N.getStr("inspector.nodePreview.highlightNodeLabel") } }); // Wrapper used for mouseover/out event handling. this.previewEl = createNode({ parent: this.el, nodeType: "span", attributes: { "title": L10N.getStr("inspector.nodePreview.selectNodeLabel") } }); if (!this.options.compact) { this.previewEl.appendChild(document.createTextNode("<")); } // Only used for ::before and ::after pseudo-elements. this.pseudoEl = createNode({ parent: this.previewEl, nodeType: "span", attributes: { "class": "pseudo-element theme-fg-color5" } }); // Tag name. this.tagNameEl = createNode({ parent: this.previewEl, nodeType: "span", attributes: { "class": "tag-name theme-fg-color3" } }); // Id attribute container. this.idEl = createNode({ parent: this.previewEl, nodeType: "span" }); if (!this.options.compact) { createNode({ parent: this.idEl, nodeType: "span", attributes: { "class": "attribute-name theme-fg-color2" }, textContent: "id" }); this.idEl.appendChild(document.createTextNode("=\"")); } else { createNode({ parent: this.idEl, nodeType: "span", attributes: { "class": "theme-fg-color6" }, textContent: "#" }); } createNode({ parent: this.idEl, nodeType: "span", attributes: { "class": "attribute-value theme-fg-color6" } }); if (!this.options.compact) { this.idEl.appendChild(document.createTextNode("\"")); } // Class attribute container. this.classEl = createNode({ parent: this.previewEl, nodeType: "span" }); if (!this.options.compact) { createNode({ parent: this.classEl, nodeType: "span", attributes: { "class": "attribute-name theme-fg-color2" }, textContent: "class" }); this.classEl.appendChild(document.createTextNode("=\"")); } else { createNode({ parent: this.classEl, nodeType: "span", attributes: { "class": "theme-fg-color6" }, textContent: "." }); } createNode({ parent: this.classEl, nodeType: "span", attributes: { "class": "attribute-value theme-fg-color6" } }); if (!this.options.compact) { this.classEl.appendChild(document.createTextNode("\"")); this.previewEl.appendChild(document.createTextNode(">")); } this.startListeners(); }, startListeners: function () { // Init events for highlighting and selecting the node. this.previewEl.addEventListener("mouseover", this.onPreviewMouseOver); this.previewEl.addEventListener("mouseout", this.onPreviewMouseOut); this.previewEl.addEventListener("click", this.onSelectElClick); this.highlightNodeEl.addEventListener("click", this.onHighlightElClick); // Start to listen for markupmutation events. this.inspector.on("markupmutation", this.onMarkupMutations); // Listen to the target node highlighter. HighlighterLock.on("highlighted", this.onHighlighterLocked); }, stopListeners: function () { HighlighterLock.off("highlighted", this.onHighlighterLocked); this.inspector.off("markupmutation", this.onMarkupMutations); this.previewEl.removeEventListener("mouseover", this.onPreviewMouseOver); this.previewEl.removeEventListener("mouseout", this.onPreviewMouseOut); this.previewEl.removeEventListener("click", this.onSelectElClick); this.highlightNodeEl.removeEventListener("click", this.onHighlightElClick); }, destroy: function () { HighlighterLock.unhighlight().catch(e => console.error(e)); this.stopListeners(); this.el.remove(); this.el = this.tagNameEl = this.idEl = this.classEl = this.pseudoEl = null; this.highlightNodeEl = this.previewEl = null; this.nodeFront = this.inspector = null; }, get highlighterUtils() { if (this.inspector && this.inspector.toolbox) { return this.inspector.toolbox.highlighterUtils; } return null; }, onPreviewMouseOver: function () { if (!this.nodeFront || !this.highlighterUtils) { return; } this.highlighterUtils.highlightNodeFront(this.nodeFront) .catch(e => console.error(e)); }, onPreviewMouseOut: function () { if (!this.nodeFront || !this.highlighterUtils) { return; } this.highlighterUtils.unhighlight() .catch(e => console.error(e)); }, onSelectElClick: function () { if (!this.nodeFront) { return; } this.inspector.selection.setNodeFront(this.nodeFront, "dom-node-preview"); }, onHighlightElClick: function (e) { e.stopPropagation(); let classList = this.highlightNodeEl.classList; let isHighlighted = classList.contains("selected"); if (isHighlighted) { classList.remove("selected"); HighlighterLock.unhighlight().then(() => { this.emit("target-highlighter-unlocked"); }, error => console.error(error)); } else { classList.add("selected"); HighlighterLock.highlight(this).then(() => { this.emit("target-highlighter-locked"); }, error => console.error(error)); } }, onHighlighterLocked: function (e, domNodePreview) { if (domNodePreview !== this) { this.highlightNodeEl.classList.remove("selected"); } }, onMarkupMutations: function (e, mutations) { if (!this.nodeFront) { return; } for (let {target} of mutations) { if (target === this.nodeFront) { // Re-render with the same nodeFront to update the output. this.render(this.nodeFront); break; } } }, render: function (nodeFront) { this.nodeFront = nodeFront; let {displayName, attributes} = nodeFront; if (nodeFront.isPseudoElement) { this.pseudoEl.textContent = nodeFront.isBeforePseudoElement ? "::before" : "::after"; this.pseudoEl.style.display = "inline"; this.tagNameEl.style.display = "none"; } else { this.tagNameEl.textContent = displayName; this.pseudoEl.style.display = "none"; this.tagNameEl.style.display = "inline"; } let idIndex = attributes.findIndex(({name}) => name === "id"); if (idIndex > -1 && attributes[idIndex].value) { this.idEl.querySelector(".attribute-value").textContent = attributes[idIndex].value; this.idEl.style.display = "inline"; } else { this.idEl.style.display = "none"; } let classIndex = attributes.findIndex(({name}) => name === "class"); if (classIndex > -1 && attributes[classIndex].value) { let value = attributes[classIndex].value; if (this.options.compact) { value = value.split(" ").join("."); } this.classEl.querySelector(".attribute-value").textContent = value; this.classEl.style.display = "inline"; } else { this.classEl.style.display = "none"; } } }; /** * HighlighterLock is a helper used to lock the highlighter on DOM nodes in the * page. * It instantiates a new highlighter that is then shared amongst all instances * of DomNodePreview. This is useful because that means showing the highlighter * on one node will unhighlight the previously highlighted one, but will not * interfere with the default inspector highlighter. */ var HighlighterLock = { highlighter: null, isShown: false, highlight: Task.async(function* (animationTargetNode) { if (!this.highlighter) { let util = animationTargetNode.inspector.toolbox.highlighterUtils; this.highlighter = yield util.getHighlighterByType("BoxModelHighlighter"); } yield this.highlighter.show(animationTargetNode.nodeFront); this.isShown = true; this.emit("highlighted", animationTargetNode); }), unhighlight: Task.async(function* () { if (!this.highlighter || !this.isShown) { return; } yield this.highlighter.hide(); this.isShown = false; this.emit("unhighlighted"); }) }; EventEmitter.decorate(HighlighterLock);