/* 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 { PSEUDO_CLASSES, } = require("resource://devtools/shared/css/constants.js"); const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); loader.lazyRequireGetter( this, "Menu", "resource://devtools/client/framework/menu.js" ); loader.lazyRequireGetter( this, "MenuItem", "resource://devtools/client/framework/menu-item.js" ); loader.lazyRequireGetter( this, "clipboardHelper", "resource://devtools/shared/platform/clipboard.js" ); 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.currentTarget; 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 ( !Element.isInstance(event.originalTarget) || 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.inspectorFront .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; } const nodeFront = this.selection.nodeFront; // 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(nodeFront); } else { // remove the node from content nodeFront.walkerFront.removeNode(nodeFront); } } /** * Duplicate the selected node */ _duplicateNode() { if ( !this.selection.isElementNode() || this.selection.isRoot() || this.selection.isAnonymousNode() || this.selection.isPseudoElementNode() ) { return; } const nodeFront = this.selection.nodeFront; nodeFront.walkerFront.duplicateNode(nodeFront).catch(console.error); } /** * Edit the outerHTML of the selected Node. */ _editHTML() { if (!this.selection.isNode()) { return; } this.markup.beginEditingHTML(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 { hud } = this.toolbox.getPanel("webconsole"); hud.ui.wrapper.dispatchEvaluateExpression("inspect($0, true)"); }); } /** * 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. */ async _useInConsole() { await this.toolbox.openSplitConsole(); 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 res = await this.toolbox.commands.scriptCommand.execute(evalString, { selectedNodeActor: this.selection.nodeFront.actorID, }); hud.setInputValue(res.result); this.inspector.emit("console-var-ready"); } _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) { return content; } return null; } _getCopySubmenu(markupContainer, isElement, isFragment) { 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: !isElement && !isFragment, click: () => this._copyInnerHTML(), }) ); copySubmenu.append( new MenuItem({ id: "node-menu-copyouter", label: INSPECTOR_L10N.getStr("inspectorCopyOuterHTML.label"), accesskey: INSPECTOR_L10N.getStr("inspectorCopyOuterHTML.accesskey"), disabled: !isElement, click: () => this._copyOuterHTML(), }) ); copySubmenu.append( new MenuItem({ id: "node-menu-copyuniqueselector", label: INSPECTOR_L10N.getStr("inspectorCopyCSSSelector.label"), accesskey: INSPECTOR_L10N.getStr("inspectorCopyCSSSelector.accesskey"), disabled: !isElement, click: () => this._copyUniqueSelector(), }) ); copySubmenu.append( new MenuItem({ id: "node-menu-copycsspath", label: INSPECTOR_L10N.getStr("inspectorCopyCSSPath.label"), accesskey: INSPECTOR_L10N.getStr("inspectorCopyCSSPath.accesskey"), disabled: !isElement, click: () => this._copyCssPath(), }) ); copySubmenu.append( new MenuItem({ id: "node-menu-copyxpath", label: INSPECTOR_L10N.getStr("inspectorCopyXPath.label"), accesskey: INSPECTOR_L10N.getStr("inspectorCopyXPath.accesskey"), disabled: !isElement, click: () => this._copyXPath(), }) ); copySubmenu.append( new MenuItem({ id: "node-menu-copyimagedatauri", label: INSPECTOR_L10N.getStr("inspectorImageDataUri.label"), disabled: !isElement || !markupContainer || !markupContainer.isPreviewable(), click: () => this._copyImageDataUri(), }) ); return copySubmenu; } _getDOMBreakpointSubmenu(isElement) { const menu = new Menu(); const mutationBreakpoints = this.selection.nodeFront.mutationBreakpoints; menu.append( new MenuItem({ id: "node-menu-mutation-breakpoint-subtree", checked: mutationBreakpoints.subtree, click: () => this.markup.toggleMutationBreakpoint("subtree"), disabled: !isElement, label: INSPECTOR_L10N.getStr("inspectorSubtreeModification.label"), type: "checkbox", }) ); menu.append( new MenuItem({ id: "node-menu-mutation-breakpoint-attribute", checked: mutationBreakpoints.attribute, click: () => this.markup.toggleMutationBreakpoint("attribute"), disabled: !isElement, label: INSPECTOR_L10N.getStr("inspectorAttributeModification.label"), type: "checkbox", }) ); menu.append( new MenuItem({ checked: mutationBreakpoints.removal, click: () => this.markup.toggleMutationBreakpoint("removal"), disabled: !isElement, label: INSPECTOR_L10N.getStr("inspectorNodeRemoval.label"), type: "checkbox", }) ); return menu; } /** * 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.toolbox.isBrowserToolbox) { 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(isElement, isFragment, isAnonymous) { const isPasteable = !isAnonymous && (isFragment || isElement) && this._getClipboardContentForPaste(); const disableAdjacentPaste = !isPasteable || !isElement || this.selection.isRoot() || this.selection.isBodyNode() || this.selection.isHeadNode(); const disableFirstLastPaste = !isPasteable || !isElement || (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 || !isElement, 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; } _getPseudoClassSubmenu(isElement) { const menu = new Menu(); // Set the pseudo classes for (const name of PSEUDO_CLASSES) { const menuitem = new MenuItem({ id: "node-menu-pseudo-" + name.substr(1), label: name.substr(1), type: "checkbox", click: () => this.inspector.togglePseudoClass(name), }); if (isElement) { const checked = this.selection.nodeFront.hasPseudoClassLock(name); menuitem.checked = checked; } else { menuitem.disabled = true; } menu.append(menuitem); } return menu; } _getEditMarkupString() { if (this.selection.isHTMLNode()) { return "inspectorHTMLEdit"; } else if (this.selection.isSVGNode()) { return "inspectorSVGEdit"; } else if (this.selection.isMathMLNode()) { return "inspectorMathMLEdit"; } return "inspectorXMLEdit"; } _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 isFragment = this.selection.isDocumentFragmentNode(); const isAnonymous = this.selection.isAnonymousNode(); const isElement = this.selection.isElementNode() && !this.selection.isPseudoElementNode(); const isDuplicatableElement = isElement && !isAnonymous && !this.selection.isRoot(); const isScreenshotable = isElement && this.selection.nodeFront.isTreeDisplayed; const menu = new Menu(); menu.append( new MenuItem({ id: "node-menu-edithtml", label: INSPECTOR_L10N.getStr(`${this._getEditMarkupString()}.label`), accesskey: INSPECTOR_L10N.getStr("inspectorHTMLEdit.accesskey"), disabled: isAnonymous || (!isElement && !isFragment), 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(isElement && !isAnonymous), }) ); menu.append( new MenuItem({ type: "separator", }) ); if ( Services.prefs.getBoolPref( "devtools.markup.mutationBreakpoints.enabled" ) && this.selection.nodeFront.mutationBreakpoints ) { menu.append( new MenuItem({ label: INSPECTOR_L10N.getStr("inspectorBreakpointSubmenu.label"), // FIXME(bug 1598952): This doesn't work in shadow trees at all, but // we still display the active menu. Also, this should probably be // enabled for ShadowRoot, at least the non-attribute breakpoints. submenu: this._getDOMBreakpointSubmenu(isElement), id: "node-menu-mutation-breakpoint", }) ); } 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.isElementNode() || this.selection.isTextNode()) { menu.append( new MenuItem({ id: "node-menu-showaccessibilityproperties", label: INSPECTOR_L10N.getStr( "inspectorShowAccessibilityProperties.label" ), click: () => this._showAccessibilityProperties(), }) ); } if (this.selection.nodeFront.customElementLocation) { menu.append( new MenuItem({ id: "node-menu-jumptodefinition", label: INSPECTOR_L10N.getStr( "inspectorCustomElementDefinition.label" ), click: () => this._jumpToCustomElementDefinition(), }) ); } menu.append( new MenuItem({ type: "separator", }) ); menu.append( new MenuItem({ label: INSPECTOR_L10N.getStr("inspectorPseudoClassSubmenu.label"), submenu: this._getPseudoClassSubmenu(isElement), }) ); 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-scrollnodeintoview", label: INSPECTOR_L10N.getStr("inspectorScrollNodeIntoView.label"), accesskey: INSPECTOR_L10N.getStr( "inspectorScrollNodeIntoView.accesskey" ), disabled: !isElement, click: () => this.markup.scrollNodeIntoView(), }) ); menu.append( new MenuItem({ type: "separator", }) ); menu.append( new MenuItem({ label: INSPECTOR_L10N.getStr("inspectorCopyHTMLSubmenu.label"), submenu: this._getCopySubmenu(markupContainer, isElement, isFragment), }) ); menu.append( new MenuItem({ label: INSPECTOR_L10N.getStr("inspectorPasteHTMLSubmenu.label"), submenu: this._getPasteSubmenu(isElement, isFragment, isAnonymous), }) ); 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), }) ); const nodeLinkMenuItems = this._getNodeLinkMenuItems(); if (nodeLinkMenuItems.filter(item => item.visible).length) { 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; } } module.exports = MarkupContextMenu;