/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ /* vim: set ft=javascript ts=2 et sw=2 tw=80: */ /* 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 Services = require("Services"); const promise = require("promise"); const { LocalizationHelper } = require("devtools/shared/l10n"); loader.lazyRequireGetter(this, "Menu", "devtools/client/framework/menu"); loader.lazyRequireGetter(this, "MenuItem", "devtools/client/framework/menu-item"); loader.lazyRequireGetter(this, "clipboardHelper", "devtools/shared/platform/clipboard"); loader.lazyGetter(this, "TOOLBOX_L10N", function() { return new LocalizationHelper("devtools/client/locales/toolbox.properties"); }); const INSPECTOR_L10N = new LocalizationHelper("devtools/client/locales/inspector.properties"); /** * Context menu for the Markup view. */ class MarkupContextMenu { constructor(markup) { this.markup = markup; this.inspector = markup.inspector; this.selection = this.inspector.selection; this.target = this.inspector.target; this.telemetry = this.inspector.telemetry; this.toolbox = this.inspector.toolbox; this.walker = this.inspector.walker; } destroy() { this.markup = null; this.inspector = null; this.selection = null; this.target = null; this.telemetry = null; this.toolbox = null; this.walker = null; } show(event) { if (!(event.originalTarget instanceof Element) || event.originalTarget.closest("input[type=text]") || event.originalTarget.closest("input:not([type])") || event.originalTarget.closest("textarea")) { return; } event.stopPropagation(); event.preventDefault(); this._openMenu({ screenX: event.screenX, screenY: event.screenY, target: event.target, }); } /** * This method is here for the benefit of copying links. */ _copyAttributeLink(link) { this.inspector.resolveRelativeURL(link, this.selection.nodeFront).then(url => { clipboardHelper.copyString(url); }, console.error); } /** * Copy the full CSS Path of the selected Node to the clipboard. */ _copyCssPath() { if (!this.selection.isNode()) { return; } this.telemetry.scalarSet("devtools.copy.full.css.selector.opened", 1); this.selection.nodeFront.getCssPath().then(path => { clipboardHelper.copyString(path); }).catch(console.error); } /** * Copy the data-uri for the currently selected image in the clipboard. */ _copyImageDataUri() { const container = this.markup.getContainer(this.selection.nodeFront); if (container && container.isPreviewable()) { container.copyImageDataUri(); } } /** * Copy the innerHTML of the selected Node to the clipboard. */ _copyInnerHTML() { this.markup.copyInnerHTML(); } /** * Copy the outerHTML of the selected Node to the clipboard. */ _copyOuterHTML() { this.markup.copyOuterHTML(); } /** * Copy a unique selector of the selected Node to the clipboard. */ _copyUniqueSelector() { if (!this.selection.isNode()) { return; } this.telemetry.scalarSet("devtools.copy.unique.css.selector.opened", 1); this.selection.nodeFront.getUniqueSelector().then(selector => { clipboardHelper.copyString(selector); }).catch(console.error); } /** * Copy the XPath of the selected Node to the clipboard. */ _copyXPath() { if (!this.selection.isNode()) { return; } this.telemetry.scalarSet("devtools.copy.xpath.opened", 1); this.selection.nodeFront.getXPath().then(path => { clipboardHelper.copyString(path); }).catch(console.error); } /** * Delete the selected node. */ _deleteNode() { if (!this.selection.isNode() || this.selection.isRoot()) { return; } // If the markup panel is active, use the markup panel to delete // the node, making this an undoable action. if (this.markup) { this.markup.deleteNode(this.selection.nodeFront); } else { // remove the node from content this.walker.removeNode(this.selection.nodeFront); } } /** * Duplicate the selected node */ _duplicateNode() { if (!this.selection.isElementNode() || this.selection.isRoot() || this.selection.isAnonymousNode() || this.selection.isPseudoElementNode()) { return; } this.walker.duplicateNode(this.selection.nodeFront).catch(console.error); } /** * Edit the outerHTML of the selected Node. */ _editHTML() { if (!this.selection.isNode()) { return; } this.markup.beginEditingOuterHTML(this.selection.nodeFront); } /** * Jumps to the custom element definition in the debugger. */ _jumpToCustomElementDefinition() { const { url, line, column } = this.selection.nodeFront.customElementLocation; this.toolbox.viewSourceInDebugger(url, line, column, null, "show_custom_element"); } /** * Add attribute to node. * Used for node context menu and shouldn't be called directly. */ _onAddAttribute() { const container = this.markup.getContainer(this.selection.nodeFront); container.addAttribute(); } /** * Copy attribute value for node. * Used for node context menu and shouldn't be called directly. */ _onCopyAttributeValue() { clipboardHelper.copyString(this.nodeMenuTriggerInfo.value); } /** * This method is here for the benefit of the node-menu-link-copy menu item * in the inspector contextual-menu. */ _onCopyLink() { this.copyAttributeLink(this.contextMenuTarget.dataset.link); } /** * Edit attribute for node. * Used for node context menu and shouldn't be called directly. */ _onEditAttribute() { const container = this.markup.getContainer(this.selection.nodeFront); container.editAttribute(this.nodeMenuTriggerInfo.name); } /** * This method is here for the benefit of the node-menu-link-follow menu item * in the inspector contextual-menu. */ _onFollowLink() { const type = this.contextMenuTarget.dataset.type; const link = this.contextMenuTarget.dataset.link; this.markup.followAttributeLink(type, link); } /** * Remove attribute from node. * Used for node context menu and shouldn't be called directly. */ _onRemoveAttribute() { const container = this.markup.getContainer(this.selection.nodeFront); container.removeAttribute(this.nodeMenuTriggerInfo.name); } /** * Paste the contents of the clipboard as adjacent HTML to the selected Node. * * @param {String} position * The position as specified for Element.insertAdjacentHTML * (i.e. "beforeBegin", "afterBegin", "beforeEnd", "afterEnd"). */ _pasteAdjacentHTML(position) { const content = this._getClipboardContentForPaste(); if (!content) { return promise.reject("No clipboard content for paste"); } const node = this.selection.nodeFront; return this.markup.insertAdjacentHTMLToNode(node, position, content); } /** * Paste the contents of the clipboard into the selected Node's inner HTML. */ _pasteInnerHTML() { const content = this._getClipboardContentForPaste(); if (!content) { return promise.reject("No clipboard content for paste"); } const node = this.selection.nodeFront; return this.markup.getNodeInnerHTML(node).then(oldContent => { this.markup.updateNodeInnerHTML(node, content, oldContent); }); } /** * Paste the contents of the clipboard into the selected Node's outer HTML. */ _pasteOuterHTML() { const content = this._getClipboardContentForPaste(); if (!content) { return promise.reject("No clipboard content for paste"); } const node = this.selection.nodeFront; return this.markup.getNodeOuterHTML(node).then(oldContent => { this.markup.updateNodeOuterHTML(node, content, oldContent); }); } /** * Show Accessibility properties for currently selected node */ async _showAccessibilityProperties() { const a11yPanel = await this.toolbox.selectTool("accessibility"); // Select the accessible object in the panel and wait for the event that // tells us it has been done. const onSelected = a11yPanel.once("new-accessible-front-selected"); a11yPanel.selectAccessibleForNode(this.selection.nodeFront, "inspector-context-menu"); await onSelected; } /** * Show DOM properties */ _showDOMProperties() { this.toolbox.openSplitConsole().then(() => { const panel = this.toolbox.getPanel("webconsole"); const jsterm = panel.hud.jsterm; jsterm.execute("inspect($0)"); jsterm.focus(); }); } /** * Use in Console. * * Takes the currently selected node in the inspector and assigns it to a * temp variable on the content window. Also opens the split console and * autofills it with the temp variable. */ _useInConsole() { this.toolbox.openSplitConsole().then(() => { const {hud} = this.toolbox.getPanel("webconsole"); const evalString = `{ let i = 0; while (window.hasOwnProperty("temp" + i) && i < 1000) { i++; } window["temp" + i] = $0; "temp" + i; }`; const options = { selectedNodeActor: this.selection.nodeFront.actorID, }; hud.jsterm.requestEvaluation(evalString, options).then((res) => { hud.setInputValue(res.result); this.inspector.emit("console-var-ready"); }); }); } _buildA11YMenuItem(menu) { if (!(this.selection.isElementNode() || this.selection.isTextNode()) || !Services.prefs.getBoolPref("devtools.accessibility.enabled")) { return; } const showA11YPropsItem = new MenuItem({ id: "node-menu-showaccessibilityproperties", label: INSPECTOR_L10N.getStr("inspectorShowAccessibilityProperties.label"), click: () => this._showAccessibilityProperties(), disabled: true, }); // Only attempt to determine if a11y props menu item needs to be enabled if // AccessibilityFront is enabled. if (this.inspector.accessibilityFront.enabled) { this._updateA11YMenuItem(showA11YPropsItem); } menu.append(showA11YPropsItem); } _getAttributesSubmenu(isEditableElement) { const attributesSubmenu = new Menu(); const nodeInfo = this.nodeMenuTriggerInfo; const isAttributeClicked = isEditableElement && nodeInfo && nodeInfo.type === "attribute"; attributesSubmenu.append(new MenuItem({ id: "node-menu-add-attribute", label: INSPECTOR_L10N.getStr("inspectorAddAttribute.label"), accesskey: INSPECTOR_L10N.getStr("inspectorAddAttribute.accesskey"), disabled: !isEditableElement, click: () => this._onAddAttribute(), })); attributesSubmenu.append(new MenuItem({ id: "node-menu-copy-attribute", label: INSPECTOR_L10N.getFormatStr("inspectorCopyAttributeValue.label", isAttributeClicked ? `${nodeInfo.value}` : ""), accesskey: INSPECTOR_L10N.getStr("inspectorCopyAttributeValue.accesskey"), disabled: !isAttributeClicked, click: () => this._onCopyAttributeValue(), })); attributesSubmenu.append(new MenuItem({ id: "node-menu-edit-attribute", label: INSPECTOR_L10N.getFormatStr("inspectorEditAttribute.label", isAttributeClicked ? `${nodeInfo.name}` : ""), accesskey: INSPECTOR_L10N.getStr("inspectorEditAttribute.accesskey"), disabled: !isAttributeClicked, click: () => this._onEditAttribute(), })); attributesSubmenu.append(new MenuItem({ id: "node-menu-remove-attribute", label: INSPECTOR_L10N.getFormatStr("inspectorRemoveAttribute.label", isAttributeClicked ? `${nodeInfo.name}` : ""), accesskey: INSPECTOR_L10N.getStr("inspectorRemoveAttribute.accesskey"), disabled: !isAttributeClicked, click: () => this._onRemoveAttribute(), })); return attributesSubmenu; } /** * Returns the clipboard content if it is appropriate for pasting * into the current node's outer HTML, otherwise returns null. */ _getClipboardContentForPaste() { const content = clipboardHelper.getText(); if (content && content.trim().length > 0) { return content; } return null; } _getCopySubmenu(markupContainer, isSelectionElement) { const copySubmenu = new Menu(); copySubmenu.append(new MenuItem({ id: "node-menu-copyinner", label: INSPECTOR_L10N.getStr("inspectorCopyInnerHTML.label"), accesskey: INSPECTOR_L10N.getStr("inspectorCopyInnerHTML.accesskey"), disabled: !isSelectionElement, click: () => this._copyInnerHTML(), })); copySubmenu.append(new MenuItem({ id: "node-menu-copyouter", label: INSPECTOR_L10N.getStr("inspectorCopyOuterHTML.label"), accesskey: INSPECTOR_L10N.getStr("inspectorCopyOuterHTML.accesskey"), disabled: !isSelectionElement, click: () => this._copyOuterHTML(), })); copySubmenu.append(new MenuItem({ id: "node-menu-copyuniqueselector", label: INSPECTOR_L10N.getStr("inspectorCopyCSSSelector.label"), accesskey: INSPECTOR_L10N.getStr("inspectorCopyCSSSelector.accesskey"), disabled: !isSelectionElement, click: () => this._copyUniqueSelector(), })); copySubmenu.append(new MenuItem({ id: "node-menu-copycsspath", label: INSPECTOR_L10N.getStr("inspectorCopyCSSPath.label"), accesskey: INSPECTOR_L10N.getStr("inspectorCopyCSSPath.accesskey"), disabled: !isSelectionElement, click: () => this._copyCssPath(), })); copySubmenu.append(new MenuItem({ id: "node-menu-copyxpath", label: INSPECTOR_L10N.getStr("inspectorCopyXPath.label"), accesskey: INSPECTOR_L10N.getStr("inspectorCopyXPath.accesskey"), disabled: !isSelectionElement, click: () => this._copyXPath(), })); copySubmenu.append(new MenuItem({ id: "node-menu-copyimagedatauri", label: INSPECTOR_L10N.getStr("inspectorImageDataUri.label"), disabled: !isSelectionElement || !markupContainer || !markupContainer.isPreviewable(), click: () => this._copyImageDataUri(), })); return copySubmenu; } /** * Link menu items can be shown or hidden depending on the context and * selected node, and their labels can vary. * * @return {Array} list of visible menu items related to links. */ _getNodeLinkMenuItems() { const linkFollow = new MenuItem({ id: "node-menu-link-follow", visible: false, click: () => this._onFollowLink(), }); const linkCopy = new MenuItem({ id: "node-menu-link-copy", visible: false, click: () => this._onCopyLink(), }); // Get information about the right-clicked node. const popupNode = this.contextMenuTarget; if (!popupNode || !popupNode.classList.contains("link")) { return [linkFollow, linkCopy]; } const type = popupNode.dataset.type; if ((type === "uri" || type === "cssresource" || type === "jsresource")) { // Links can't be opened in new tabs in the browser toolbox. if (type === "uri" && !this.target.chrome) { linkFollow.visible = true; linkFollow.label = INSPECTOR_L10N.getStr( "inspector.menu.openUrlInNewTab.label"); } else if (type === "cssresource") { linkFollow.visible = true; linkFollow.label = TOOLBOX_L10N.getStr( "toolbox.viewCssSourceInStyleEditor.label"); } else if (type === "jsresource") { linkFollow.visible = true; linkFollow.label = TOOLBOX_L10N.getStr( "toolbox.viewJsSourceInDebugger.label"); } linkCopy.visible = true; linkCopy.label = INSPECTOR_L10N.getStr( "inspector.menu.copyUrlToClipboard.label"); } else if (type === "idref") { linkFollow.visible = true; linkFollow.label = INSPECTOR_L10N.getFormatStr( "inspector.menu.selectElement.label", popupNode.dataset.link); } return [linkFollow, linkCopy]; } _getPasteSubmenu(isEditableElement) { const isPasteable = isEditableElement && this._getClipboardContentForPaste(); const disableAdjacentPaste = !isPasteable || this.selection.isRoot() || this.selection.isBodyNode() || this.selection.isHeadNode(); const disableFirstLastPaste = !isPasteable || (this.selection.isHTMLNode() && this.selection.isRoot()); const pasteSubmenu = new Menu(); pasteSubmenu.append(new MenuItem({ id: "node-menu-pasteinnerhtml", label: INSPECTOR_L10N.getStr("inspectorPasteInnerHTML.label"), accesskey: INSPECTOR_L10N.getStr("inspectorPasteInnerHTML.accesskey"), disabled: !isPasteable, click: () => this._pasteInnerHTML(), })); pasteSubmenu.append(new MenuItem({ id: "node-menu-pasteouterhtml", label: INSPECTOR_L10N.getStr("inspectorPasteOuterHTML.label"), accesskey: INSPECTOR_L10N.getStr("inspectorPasteOuterHTML.accesskey"), disabled: !isPasteable, click: () => this._pasteOuterHTML(), })); pasteSubmenu.append(new MenuItem({ id: "node-menu-pastebefore", label: INSPECTOR_L10N.getStr("inspectorHTMLPasteBefore.label"), accesskey: INSPECTOR_L10N.getStr("inspectorHTMLPasteBefore.accesskey"), disabled: disableAdjacentPaste, click: () => this._pasteAdjacentHTML("beforeBegin"), })); pasteSubmenu.append(new MenuItem({ id: "node-menu-pasteafter", label: INSPECTOR_L10N.getStr("inspectorHTMLPasteAfter.label"), accesskey: INSPECTOR_L10N.getStr("inspectorHTMLPasteAfter.accesskey"), disabled: disableAdjacentPaste, click: () => this._pasteAdjacentHTML("afterEnd"), })); pasteSubmenu.append(new MenuItem({ id: "node-menu-pastefirstchild", label: INSPECTOR_L10N.getStr("inspectorHTMLPasteFirstChild.label"), accesskey: INSPECTOR_L10N.getStr("inspectorHTMLPasteFirstChild.accesskey"), disabled: disableFirstLastPaste, click: () => this._pasteAdjacentHTML("afterBegin"), })); pasteSubmenu.append(new MenuItem({ id: "node-menu-pastelastchild", label: INSPECTOR_L10N.getStr("inspectorHTMLPasteLastChild.label"), accesskey: INSPECTOR_L10N.getStr("inspectorHTMLPasteLastChild.accesskey"), disabled: disableFirstLastPaste, click: () => this._pasteAdjacentHTML("beforeEnd"), })); return pasteSubmenu; } _openMenu({ target, screenX = 0, screenY = 0 } = {}) { if (this.selection.isSlotted()) { // Slotted elements should not show any context menu. return null; } const markupContainer = this.markup.getContainer(this.selection.nodeFront); this.contextMenuTarget = target; this.nodeMenuTriggerInfo = markupContainer && markupContainer.editor.getInfoAtNode(target); const isSelectionElement = this.selection.isElementNode() && !this.selection.isPseudoElementNode(); const isEditableElement = isSelectionElement && !this.selection.isAnonymousNode(); const isDuplicatableElement = isSelectionElement && !this.selection.isAnonymousNode() && !this.selection.isRoot(); const isScreenshotable = isSelectionElement && this.selection.nodeFront.isTreeDisplayed; const menu = new Menu(); menu.append(new MenuItem({ id: "node-menu-edithtml", label: INSPECTOR_L10N.getStr("inspectorHTMLEdit.label"), accesskey: INSPECTOR_L10N.getStr("inspectorHTMLEdit.accesskey"), disabled: !isEditableElement, click: () => this._editHTML(), })); menu.append(new MenuItem({ id: "node-menu-add", label: INSPECTOR_L10N.getStr("inspectorAddNode.label"), accesskey: INSPECTOR_L10N.getStr("inspectorAddNode.accesskey"), disabled: !this.inspector.canAddHTMLChild(), click: () => this.inspector.addNode(), })); menu.append(new MenuItem({ id: "node-menu-duplicatenode", label: INSPECTOR_L10N.getStr("inspectorDuplicateNode.label"), disabled: !isDuplicatableElement, click: () => this._duplicateNode(), })); menu.append(new MenuItem({ id: "node-menu-delete", label: INSPECTOR_L10N.getStr("inspectorHTMLDelete.label"), accesskey: INSPECTOR_L10N.getStr("inspectorHTMLDelete.accesskey"), disabled: !this.markup.isDeletable(this.selection.nodeFront), click: () => this._deleteNode(), })); menu.append(new MenuItem({ label: INSPECTOR_L10N.getStr("inspectorAttributesSubmenu.label"), accesskey: INSPECTOR_L10N.getStr("inspectorAttributesSubmenu.accesskey"), submenu: this._getAttributesSubmenu(isEditableElement), })); menu.append(new MenuItem({ type: "separator", })); // Set the pseudo classes for (const name of ["hover", "active", "focus", "focus-within"]) { const menuitem = new MenuItem({ id: "node-menu-pseudo-" + name, label: name, type: "checkbox", click: () => this.inspector.togglePseudoClass(":" + name), }); if (isSelectionElement) { const checked = this.selection.nodeFront.hasPseudoClassLock(":" + name); menuitem.checked = checked; } else { menuitem.disabled = true; } menu.append(menuitem); } menu.append(new MenuItem({ type: "separator", })); menu.append(new MenuItem({ label: INSPECTOR_L10N.getStr("inspectorCopyHTMLSubmenu.label"), submenu: this._getCopySubmenu(markupContainer, isSelectionElement), })); menu.append(new MenuItem({ label: INSPECTOR_L10N.getStr("inspectorPasteHTMLSubmenu.label"), submenu: this._getPasteSubmenu(isEditableElement), })); menu.append(new MenuItem({ type: "separator", })); const isNodeWithChildren = this.selection.isNode() && markupContainer.hasChildren; menu.append(new MenuItem({ id: "node-menu-expand", label: INSPECTOR_L10N.getStr("inspectorExpandNode.label"), disabled: !isNodeWithChildren, click: () => this.markup.expandAll(this.selection.nodeFront), })); menu.append(new MenuItem({ id: "node-menu-collapse", label: INSPECTOR_L10N.getStr("inspectorCollapseAll.label"), disabled: !isNodeWithChildren || !markupContainer.expanded, click: () => this.markup.collapseAll(this.selection.nodeFront), })); menu.append(new MenuItem({ type: "separator", })); menu.append(new MenuItem({ id: "node-menu-scrollnodeintoview", label: INSPECTOR_L10N.getStr("inspectorScrollNodeIntoView.label"), accesskey: INSPECTOR_L10N.getStr("inspectorScrollNodeIntoView.accesskey"), disabled: !isSelectionElement, click: () => this.markup.scrollNodeIntoView(), })); menu.append(new MenuItem({ id: "node-menu-screenshotnode", label: INSPECTOR_L10N.getStr("inspectorScreenshotNode.label"), disabled: !isScreenshotable, click: () => this.inspector.screenshotNode().catch(console.error), })); menu.append(new MenuItem({ id: "node-menu-useinconsole", label: INSPECTOR_L10N.getStr("inspectorUseInConsole.label"), click: () => this._useInConsole(), })); menu.append(new MenuItem({ id: "node-menu-showdomproperties", label: INSPECTOR_L10N.getStr("inspectorShowDOMProperties.label"), click: () => this._showDOMProperties(), })); if (this.selection.nodeFront.customElementLocation) { menu.append(new MenuItem({ type: "separator", })); menu.append(new MenuItem({ id: "node-menu-jumptodefinition", label: INSPECTOR_L10N.getStr("inspectorCustomElementDefinition.label"), click: () => this._jumpToCustomElementDefinition(), })); } this._buildA11YMenuItem(menu); const nodeLinkMenuItems = this._getNodeLinkMenuItems(); if (nodeLinkMenuItems.filter(item => item.visible).length > 0) { menu.append(new MenuItem({ id: "node-menu-link-separator", type: "separator", })); } for (const menuitem of nodeLinkMenuItems) { menu.append(menuitem); } menu.popup(screenX, screenY, this.toolbox.doc); return menu; } async _updateA11YMenuItem(menuItem) { const hasMethod = await this.target.actorHasMethod( "domwalker", "hasAccessibilityProperties").catch( // Connection to DOMWalker might have been already closed. error => console.warn(error)); if (!hasMethod) { return; } const hasA11YProps = await this.walker.hasAccessibilityProperties( this.selection.nodeFront); if (hasA11YProps) { const menuItemEl = Menu.getMenuElementById(menuItem.id, this.toolbox.doc); menuItemEl.disabled = menuItem.disabled = false; } this.inspector.emit("node-menu-updated"); } } module.exports = MarkupContextMenu;