context-menu.js (11892B)
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 Menu = require("resource://devtools/client/framework/menu.js"); 8 const MenuItem = require("resource://devtools/client/framework/menu-item.js"); 9 10 const { 11 MESSAGE_SOURCE, 12 } = require("resource://devtools/client/webconsole/constants.js"); 13 14 const clipboardHelper = require("resource://devtools/shared/platform/clipboard.js"); 15 const { 16 l10n, 17 } = require("resource://devtools/client/webconsole/utils/messages.js"); 18 const actions = require("resource://devtools/client/webconsole/actions/index.js"); 19 20 loader.lazyRequireGetter( 21 this, 22 "saveAs", 23 "resource://devtools/shared/DevToolsUtils.js", 24 true 25 ); 26 loader.lazyRequireGetter( 27 this, 28 "openContentLink", 29 "resource://devtools/client/shared/link.js", 30 true 31 ); 32 loader.lazyRequireGetter( 33 this, 34 "getElementText", 35 "resource://devtools/client/webconsole/utils/clipboard.js", 36 true 37 ); 38 39 /** 40 * Create a Menu instance for the webconsole. 41 * 42 * @param {Event} context menu event 43 * {object} message (optional) message object containing metadata such as: 44 * - {String} source 45 * - {String} request 46 * @param {object} options 47 * - {Actions} bound actions 48 * - {WebConsoleWrapper} wrapper instance used for accessing properties like the store 49 * and window. 50 */ 51 function createContextMenu(event, message, webConsoleWrapper) { 52 const { target } = event; 53 const { parentNode, toolbox, hud } = webConsoleWrapper; 54 const store = webConsoleWrapper.getStore(); 55 const { dispatch } = store; 56 57 const messageEl = target.closest(".message"); 58 const clipboardText = getElementText(messageEl); 59 60 const linkEl = target.closest("a[href]"); 61 const url = linkEl && linkEl.href; 62 63 const messageVariable = target.closest(".objectBox"); 64 // Ensure that console.group and console.groupCollapsed commands are not captured 65 const variableText = 66 messageVariable && 67 !messageEl.classList.contains("startGroup") && 68 !messageEl.classList.contains("startGroupCollapsed") 69 ? messageVariable.textContent 70 : null; 71 72 // Retrieve closes actor id from the DOM. 73 const actorEl = 74 target.closest("[data-link-actor-id]") || 75 target.querySelector("[data-link-actor-id]"); 76 const actor = actorEl ? actorEl.dataset.linkActorId : null; 77 78 const rootObjectInspector = target.closest(".object-inspector"); 79 const rootActor = rootObjectInspector 80 ? rootObjectInspector.querySelector("[data-link-actor-id]") 81 : null; 82 // We can have object which are not displayed inside an ObjectInspector (e.g. Errors), 83 // so let's default to `actor`. 84 const rootActorId = rootActor ? rootActor.dataset.linkActorId : actor; 85 86 const elementNode = 87 target.closest(".objectBox-node") || target.closest(".objectBox-textNode"); 88 const isConnectedElement = 89 elementNode && elementNode.querySelector(".open-inspector") !== null; 90 91 const win = parentNode.ownerDocument.defaultView; 92 const selection = win.getSelection(); 93 94 const { source, request, messageId } = message || {}; 95 96 const menu = new Menu({ id: "webconsole-menu" }); 97 98 // Copy URL for a network request. 99 menu.append( 100 new MenuItem({ 101 id: "console-menu-copy-url", 102 label: l10n.getStr("webconsole.menu.copyURL.label"), 103 accesskey: l10n.getStr("webconsole.menu.copyURL.accesskey"), 104 visible: source === MESSAGE_SOURCE.NETWORK, 105 click: () => { 106 if (!request) { 107 return; 108 } 109 clipboardHelper.copyString(request.url); 110 }, 111 }) 112 ); 113 114 if (toolbox && request) { 115 // Open Network message in the Network panel. 116 menu.append( 117 new MenuItem({ 118 id: "console-menu-open-in-network-panel", 119 label: l10n.getStr("webconsole.menu.openInNetworkPanel.label"), 120 accesskey: l10n.getStr("webconsole.menu.openInNetworkPanel.accesskey"), 121 visible: source === MESSAGE_SOURCE.NETWORK, 122 click: () => dispatch(actions.openNetworkPanel(message.messageId)), 123 }) 124 ); 125 // Resend Network message. 126 menu.append( 127 new MenuItem({ 128 id: "console-menu-resend-network-request", 129 label: l10n.getStr("webconsole.menu.resendNetworkRequest.label"), 130 accesskey: l10n.getStr( 131 "webconsole.menu.resendNetworkRequest.accesskey" 132 ), 133 visible: source === MESSAGE_SOURCE.NETWORK, 134 click: () => dispatch(actions.resendNetworkRequest(messageId)), 135 }) 136 ); 137 } 138 139 // Open URL in a new tab for a network request. 140 menu.append( 141 new MenuItem({ 142 id: "console-menu-open-url", 143 label: l10n.getStr("webconsole.menu.openURL.label"), 144 accesskey: l10n.getStr("webconsole.menu.openURL.accesskey"), 145 visible: source === MESSAGE_SOURCE.NETWORK, 146 click: () => { 147 if (!request) { 148 return; 149 } 150 openContentLink(request.url); 151 }, 152 }) 153 ); 154 155 // Open DOM node in the Inspector panel. 156 const contentDomReferenceEl = target.closest( 157 "[data-link-content-dom-reference]" 158 ); 159 if (isConnectedElement && contentDomReferenceEl) { 160 const contentDomReference = contentDomReferenceEl.getAttribute( 161 "data-link-content-dom-reference" 162 ); 163 164 menu.append( 165 new MenuItem({ 166 id: "console-menu-open-node", 167 label: l10n.getStr("webconsole.menu.openNodeInInspector.label"), 168 accesskey: l10n.getStr("webconsole.menu.openNodeInInspector.accesskey"), 169 disabled: false, 170 click: () => 171 dispatch( 172 actions.openNodeInInspector(JSON.parse(contentDomReference)) 173 ), 174 }) 175 ); 176 } 177 178 // Store as global variable. 179 menu.append( 180 new MenuItem({ 181 id: "console-menu-store", 182 label: l10n.getStr("webconsole.menu.storeAsGlobalVar.label"), 183 accesskey: l10n.getStr("webconsole.menu.storeAsGlobalVar.accesskey"), 184 disabled: !actor, 185 click: () => dispatch(actions.storeAsGlobal(actor)), 186 }) 187 ); 188 189 // Copy message or grip. 190 menu.append( 191 new MenuItem({ 192 id: "console-menu-copy", 193 label: l10n.getStr("webconsole.menu.copyMessage.label"), 194 accesskey: l10n.getStr("webconsole.menu.copyMessage.accesskey"), 195 // Disabled if there is no selection and no message element available to copy. 196 disabled: selection.isCollapsed && !clipboardText, 197 click: () => { 198 if (selection.isCollapsed) { 199 // If the selection is empty/collapsed, copy the text content of the 200 // message for which the context menu was opened. 201 clipboardHelper.copyString(clipboardText); 202 } else { 203 clipboardHelper.copyString(selection.toString()); 204 } 205 }, 206 }) 207 ); 208 209 // Copy message object. 210 menu.append( 211 new MenuItem({ 212 id: "console-menu-copy-object", 213 label: l10n.getStr("webconsole.menu.copyObject.label"), 214 accesskey: l10n.getStr("webconsole.menu.copyObject.accesskey"), 215 // Disabled if there is no actor and no variable text associated. 216 disabled: !actor && !variableText, 217 click: () => dispatch(actions.copyMessageObject(actor, variableText)), 218 }) 219 ); 220 221 // Export to clipboard 222 menu.append( 223 new MenuItem({ 224 id: "console-menu-export-clipboard", 225 label: l10n.getStr("webconsole.menu.copyAllMessages.label"), 226 accesskey: l10n.getStr("webconsole.menu.copyAllMessages.accesskey"), 227 disabled: false, 228 async click() { 229 const outputText = 230 await getUnvirtualizedConsoleOutputText(webConsoleWrapper); 231 clipboardHelper.copyString(outputText); 232 }, 233 }) 234 ); 235 236 // Export to file 237 menu.append( 238 new MenuItem({ 239 id: "console-menu-export-file", 240 label: l10n.getStr("webconsole.menu.saveAllMessagesFile.label"), 241 accesskey: l10n.getStr("webconsole.menu.saveAllMessagesFile.accesskey"), 242 disabled: false, 243 // Note: not async, but returns a promise for the actual save. 244 click: async () => { 245 const date = new Date(); 246 const suggestedName = 247 `console-export-${date.getFullYear()}-` + 248 `${date.getMonth() + 1}-${date.getDate()}_${date.getHours()}-` + 249 `${date.getMinutes()}-${date.getSeconds()}.log`; 250 const outputText = 251 await getUnvirtualizedConsoleOutputText(webConsoleWrapper); 252 const data = new TextEncoder().encode(outputText); 253 saveAs(window, data, suggestedName); 254 }, 255 }) 256 ); 257 258 // Open object in sidebar. 259 const shouldOpenSidebar = store.getState().prefs.sidebarToggle; 260 if (shouldOpenSidebar) { 261 menu.append( 262 new MenuItem({ 263 id: "console-menu-open-sidebar", 264 label: l10n.getStr("webconsole.menu.openInSidebar.label1"), 265 accesskey: l10n.getStr("webconsole.menu.openInSidebar.accesskey"), 266 disabled: !rootActorId, 267 click: () => dispatch(actions.openSidebar(messageId, rootActorId)), 268 }) 269 ); 270 } 271 272 if (url) { 273 menu.append( 274 new MenuItem({ 275 id: "console-menu-open-url", 276 label: l10n.getStr("webconsole.menu.openURL.label"), 277 accesskey: l10n.getStr("webconsole.menu.openURL.accesskey"), 278 click: () => 279 openContentLink(url, { 280 inBackground: true, 281 relatedToCurrent: true, 282 }), 283 }) 284 ); 285 menu.append( 286 new MenuItem({ 287 id: "console-menu-copy-url", 288 label: l10n.getStr("webconsole.menu.copyURL.label"), 289 accesskey: l10n.getStr("webconsole.menu.copyURL.accesskey"), 290 click: () => clipboardHelper.copyString(url), 291 }) 292 ); 293 } 294 295 // Emit the "menu-open" event for testing. 296 const { screenX, screenY } = event; 297 menu.once("open", () => webConsoleWrapper.emitForTests("menu-open")); 298 menu.popup(screenX, screenY, hud.chromeWindow.document); 299 300 return menu; 301 } 302 303 exports.createContextMenu = createContextMenu; 304 305 /** 306 * Returns the whole text content of the console output. 307 * We're creating a new ConsoleOutput using the current store, turning off virtualization 308 * so we can have access to all the messages. 309 * 310 * @param {WebConsoleWrapper} webConsoleWrapper 311 * @returns Promise<String> 312 */ 313 async function getUnvirtualizedConsoleOutputText(webConsoleWrapper) { 314 return new Promise(resolve => { 315 const ReactDOM = require("resource://devtools/client/shared/vendor/react-dom.mjs"); 316 const { 317 createElement, 318 createFactory, 319 } = require("resource://devtools/client/shared/vendor/react.mjs"); 320 const ConsoleOutput = createFactory( 321 require("resource://devtools/client/webconsole/components/Output/ConsoleOutput.js") 322 ); 323 const { 324 Provider, 325 createProvider, 326 } = require("resource://devtools/client/shared/vendor/react-redux.js"); 327 328 const { parentNode, toolbox } = webConsoleWrapper; 329 const doc = parentNode.ownerDocument; 330 331 // Create an element that won't impact the layout of the console 332 const singleUseElement = doc.createElement("section"); 333 singleUseElement.classList.add("clipboard-only"); 334 doc.body.append(singleUseElement); 335 336 const consoleOutput = ConsoleOutput({ 337 serviceContainer: { 338 ...webConsoleWrapper.getServiceContainer(), 339 preventStacktraceInitialRenderDelay: true, 340 }, 341 disableVirtualization: true, 342 }); 343 344 ReactDOM.render( 345 createElement( 346 Provider, 347 { 348 store: webConsoleWrapper.getStore(), 349 }, 350 toolbox 351 ? createElement( 352 createProvider(toolbox.commands.targetCommand.storeId), 353 { store: toolbox.commands.targetCommand.store }, 354 consoleOutput 355 ) 356 : consoleOutput 357 ), 358 singleUseElement, 359 () => { 360 resolve(getElementText(singleUseElement)); 361 singleUseElement.remove(); 362 ReactDOM.unmountComponentAtNode(singleUseElement); 363 } 364 ); 365 }); 366 }