Bug 1456852 - Programatically create the edit menu in the browser console input;r=nchevobbe draft
authorBrian Grinstead <bgrinstead@mozilla.com>
Tue, 17 Jul 2018 11:55:49 -0700
changeset 819378 8000147713000a30af48f1da17d50356a4cd4a04
parent 819215 547144f5596c1a146b208d68d93950a6313080ca
push id116541
push userbgrinstead@mozilla.com
push dateTue, 17 Jul 2018 19:01:28 +0000
reviewersnchevobbe
bugs1456852
milestone63.0a1
Bug 1456852 - Programatically create the edit menu in the browser console input;r=nchevobbe This allows us to open the context menu directly from webconsole.html when it's running as a top level window. Ultimately, I'd like to not have to use special handling in the console - all top-level windows should get the edit menu working automatically for HTML inputs - but this lets us prove it out as a first consumer. MozReview-Commit-ID: BYisQDtXWe4
devtools/client/framework/menu-item.js
devtools/client/framework/menu.js
devtools/client/framework/test/browser_menu_api.js
devtools/client/webconsole/browserconsole.xul
devtools/client/webconsole/components/App.js
devtools/client/webconsole/components/JSTerm.js
devtools/client/webconsole/index.html
devtools/client/webconsole/test/mochitest/browser_console_context_menu_entries.js
devtools/client/webconsole/utils/context-menu.js
devtools/client/webconsole/webconsole-output-wrapper.js
--- a/devtools/client/framework/menu-item.js
+++ b/devtools/client/framework/menu-item.js
@@ -46,28 +46,30 @@
  *      the type: 'submenu' can be omitted. If the value is not a Menu then it
  *      will be automatically converted to one using Menu.buildFromTemplate.
  *    Boolean visible
  *      If false, the menu item will be entirely hidden.
  */
 function MenuItem({
     accelerator = null,
     accesskey = null,
+    l10nID = null,
     checked = false,
     click = () => {},
     disabled = false,
     hover = () => {},
     id = null,
     label = "",
     submenu = null,
     type = "normal",
     visible = true,
 } = { }) {
   this.accelerator = accelerator;
   this.accesskey = accesskey;
+  this.l10nID = l10nID;
   this.checked = checked;
   this.click = click;
   this.disabled = disabled;
   this.hover = hover;
   this.id = id;
   this.label = label;
   this.submenu = submenu;
   this.type = type;
--- a/devtools/client/framework/menu.js
+++ b/devtools/client/framework/menu.js
@@ -131,65 +131,32 @@ Menu.prototype._createMenuItems = functi
     }
 
     if (item.submenu) {
       const menupopup = doc.createElementNS(XUL_NS, "menupopup");
       item.submenu._createMenuItems(menupopup);
 
       const menu = doc.createElementNS(XUL_NS, "menu");
       menu.appendChild(menupopup);
-      menu.setAttribute("label", item.label);
-      if (item.disabled) {
-        menu.setAttribute("disabled", "true");
-      }
-      if (item.accelerator) {
-        menu.setAttribute("acceltext", item.accelerator);
-      }
-      if (item.accesskey) {
-        menu.setAttribute("accesskey", item.accesskey);
-      }
-      if (item.id) {
-        menu.id = item.id;
-      }
+      applyItemAttributesToNode(item, menu);
       parent.appendChild(menu);
     } else if (item.type === "separator") {
       const menusep = doc.createElementNS(XUL_NS, "menuseparator");
       parent.appendChild(menusep);
     } else {
       const menuitem = doc.createElementNS(XUL_NS, "menuitem");
-      menuitem.setAttribute("label", item.label);
+      applyItemAttributesToNode(item, menuitem);
+
       menuitem.addEventListener("command", () => {
         item.click();
       });
       menuitem.addEventListener("DOMMenuItemActive", () => {
         item.hover();
       });
 
-      if (item.type === "checkbox") {
-        menuitem.setAttribute("type", "checkbox");
-      }
-      if (item.type === "radio") {
-        menuitem.setAttribute("type", "radio");
-      }
-      if (item.disabled) {
-        menuitem.setAttribute("disabled", "true");
-      }
-      if (item.checked) {
-        menuitem.setAttribute("checked", "true");
-      }
-      if (item.accelerator) {
-        menuitem.setAttribute("acceltext", item.accelerator);
-      }
-      if (item.accesskey) {
-        menuitem.setAttribute("accesskey", item.accesskey);
-      }
-      if (item.id) {
-        menuitem.id = item.id;
-      }
-
       parent.appendChild(menuitem);
     }
   });
 };
 
 Menu.setApplicationMenu = () => {
   throw Error("Not implemented");
 };
@@ -197,9 +164,38 @@ Menu.setApplicationMenu = () => {
 Menu.sendActionToFirstResponder = () => {
   throw Error("Not implemented");
 };
 
 Menu.buildFromTemplate = () => {
   throw Error("Not implemented");
 };
 
+function applyItemAttributesToNode(item, node) {
+  if (item.l10nID) {
+    node.setAttribute("data-l10n-id", item.l10nID);
+  } else {
+    node.setAttribute("label", item.label);
+    if (item.accelerator) {
+      node.setAttribute("acceltext", item.accelerator);
+    }
+    if (item.accesskey) {
+      node.setAttribute("accesskey", item.accesskey);
+    }
+  }
+  if (item.type === "checkbox") {
+    node.setAttribute("type", "checkbox");
+  }
+  if (item.type === "radio") {
+    node.setAttribute("type", "radio");
+  }
+  if (item.disabled) {
+    node.setAttribute("disabled", "true");
+  }
+  if (item.checked) {
+    node.setAttribute("checked", "true");
+  }
+  if (item.id) {
+    node.id = item.id;
+  }
+}
+
 module.exports = Menu;
--- a/devtools/client/framework/test/browser_menu_api.js
+++ b/devtools/client/framework/test/browser_menu_api.js
@@ -60,16 +60,19 @@ async function testMenuPopup(toolbox) {
     new MenuItem({
       label: "Radio Item",
       type: "radio",
     }),
     new MenuItem({
       label: "Disabled Item",
       disabled: true,
     }),
+    new MenuItem({
+      l10nID: "foo",
+    }),
   ];
 
   for (const item of MENU_ITEMS) {
     menu.append(item);
   }
 
   // Append an invisible MenuItem, which shouldn't show up in the DOM
   menu.append(new MenuItem({
@@ -97,16 +100,18 @@ async function testMenuPopup(toolbox) {
 
   is(menuItems[2].getAttribute("label"), MENU_ITEMS[2].label, "Correct label");
   is(menuItems[2].getAttribute("type"), "radio", "Correct type attr");
   ok(!menuItems[2].hasAttribute("checked"), "Doesn't have checked attr");
 
   is(menuItems[3].getAttribute("label"), MENU_ITEMS[3].label, "Correct label");
   is(menuItems[3].getAttribute("disabled"), "true", "disabled attr menuitem");
 
+  is(menuItems[4].getAttribute("data-l10n-id"), MENU_ITEMS[4].l10nID, "Correct localization attribute");
+
   await once(menu, "open");
   const closed = once(menu, "close");
   EventUtils.synthesizeMouseAtCenter(menuItems[0], {}, toolbox.win);
   await closed;
   ok(clickFired, "Click has fired");
 
   ok(!toolbox.doc.querySelector("#menu-popup"), "Popup removed from the DOM");
 }
@@ -122,17 +127,17 @@ async function testSubmenu(toolbox) {
   submenu.append(new MenuItem({
     label: "Submenu item",
     click: () => {
       info("Click callback has fired for submenu item");
       clickFired = true;
     },
   }));
   menu.append(new MenuItem({
-    label: "Submenu parent",
+    l10nID: "submenu-parent",
     submenu: submenu,
   }));
   menu.append(new MenuItem({
     label: "Submenu parent with attributes",
     id: "submenu-parent-with-attrs",
     submenu: submenu,
     accesskey: "A",
     disabled: true,
@@ -140,18 +145,19 @@ async function testSubmenu(toolbox) {
 
   menu.popup(0, 0, toolbox);
   ok(toolbox.doc.querySelector("#menu-popup"), "A popup is in the DOM");
   is(toolbox.doc.querySelectorAll("#menu-popup > menuitem").length, 0,
     "No menuitem children");
 
   const menus = toolbox.doc.querySelectorAll("#menu-popup > menu");
   is(menus.length, 2, "Correct number of menus");
-  is(menus[0].getAttribute("label"), "Submenu parent", "Correct label");
+  ok(!menus[0].hasAttribute("label"), "No label: should be set by localization");
   ok(!menus[0].hasAttribute("disabled"), "Correct disabled state");
+  is(menus[0].getAttribute("data-l10n-id"), "submenu-parent", "Correct localization attribute");
 
   is(menus[1].getAttribute("accesskey"), "A", "Correct accesskey");
   ok(menus[1].hasAttribute("disabled"), "Correct disabled state");
   is(menus[1].id, "submenu-parent-with-attrs", "Correct id");
 
   const subMenuItems = menus[0].querySelectorAll("menupopup > menuitem");
   is(subMenuItems.length, 1, "Correct number of submenu items");
   is(subMenuItems[0].getAttribute("label"), "Submenu item", "Correct label");
--- a/devtools/client/webconsole/browserconsole.xul
+++ b/devtools/client/webconsole/browserconsole.xul
@@ -6,11 +6,13 @@
 <window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
         id="devtools-webconsole"
         macanimationtype="document"
         fullscreenbutton="true"
         title=""
         windowtype="devtools:webconsole"
         width="900" height="350"
         persist="screenX screenY width height sizemode">
+  <link rel="localization" href="toolkit/main-window/editmenu.ftl"/>
+  <script type="text/javascript" src="chrome://global/content/l10n.js"></script>
   <popupset></popupset>
   <iframe src="index.html" flex="1"></iframe>
 </window>
--- a/devtools/client/webconsole/components/App.js
+++ b/devtools/client/webconsole/components/App.js
@@ -158,16 +158,17 @@ class App extends Component {
           serviceContainer,
         }),
         NotificationBox({
           id: "webconsole-notificationbox",
           notifications,
         }),
         JSTerm({
           hud,
+          serviceContainer,
           onPaste: this.onPaste,
           codeMirrorEnabled: jstermCodeMirror,
         }),
       )
     );
   }
 }
 
--- a/devtools/client/webconsole/components/JSTerm.js
+++ b/devtools/client/webconsole/components/JSTerm.js
@@ -65,16 +65,18 @@ class JSTerm extends Component {
       clearHistory: PropTypes.func.isRequired,
       // Returns previous or next value from the history
       // (depending on direction argument).
       getValueFromHistory: PropTypes.func.isRequired,
       // History of executed expression (state).
       history: PropTypes.object.isRequired,
       // Console object.
       hud: PropTypes.object.isRequired,
+      // Needed for opening context menu
+      serviceContainer: PropTypes.object.isRequired,
       // Handler for clipboard 'paste' event (also used for 'drop' event, callback).
       onPaste: PropTypes.func,
       codeMirrorEnabled: PropTypes.bool,
       // Update position in the history after executing an expression (action).
       updatePlaceHolder: PropTypes.func.isRequired,
     };
   }
 
@@ -92,16 +94,17 @@ class JSTerm extends Component {
      * Stores the data for the last completion.
      * @type object
      */
     this.lastCompletion = { value: null };
 
     this._keyPress = this._keyPress.bind(this);
     this._inputEventHandler = this._inputEventHandler.bind(this);
     this._blurEventHandler = this._blurEventHandler.bind(this);
+    this.onContextMenu = this.onContextMenu.bind(this);
 
     this.SELECTED_FRAME = -1;
 
     /**
      * Array that caches the user input suggestions received from the server.
      * @private
      * @type array
      */
@@ -1501,16 +1504,27 @@ class JSTerm extends Component {
    // Calculate the width of the chevron placed at the beginning of the input
     // box. Remove 4 more pixels to accommodate the padding of the popup.
     const doc = this.hud.document;
     return doc.defaultView
       .getComputedStyle(this.inputNode)
       .paddingLeft.replace(/[^0-9.]/g, "") - 4;
   }
 
+  onContextMenu(e) {
+    // The toolbox does it's own edit menu handling with
+    // toolbox-textbox-context-popup and friends. For now, fall
+    // back to use that if running inside the toolbox, but use our
+    // own menu when running in the Browser Console (see Bug 1476097).
+    if (this.props.hud.isBrowserConsole &&
+        Services.prefs.getBoolPref("devtools.browserconsole.html")) {
+      this.props.serviceContainer.openEditContextMenu(e);
+    }
+  }
+
   destroy() {
     this.clearCompletion();
 
     this.webConsoleClient.clearNetworkRequests();
     if (this.hud.outputNode) {
       // We do this because it's much faster than letting React handle the ConsoleOutput
       // unmounting.
       this.hud.outputNode.innerHTML = "";
@@ -1543,16 +1557,17 @@ class JSTerm extends Component {
     }
 
     if (this.props.codeMirrorEnabled) {
       return dom.div({
         className: "jsterm-input-container devtools-monospace",
         key: "jsterm-container",
         style: {direction: "ltr"},
         "aria-live": "off",
+        onContextMenu: this.onContextMenu,
         ref: node => {
           this.node = node;
         },
       });
     }
 
     const {
       onPaste
@@ -1579,16 +1594,17 @@ class JSTerm extends Component {
           tabIndex: "0",
           rows: "1",
           "aria-autocomplete": "list",
           ref: node => {
             this.inputNode = node;
           },
           onPaste: onPaste,
           onDrop: onPaste,
+          onContextMenu: this.onContextMenu,
         })
       )
     );
   }
 }
 
 // Redux connect
 
--- a/devtools/client/webconsole/index.html
+++ b/devtools/client/webconsole/index.html
@@ -4,25 +4,28 @@
 <!DOCTYPE html>
 <html dir=""
       id="devtools-webconsole"
       windowtype="devtools:webconsole"
       width="900" height="350"
       persist="screenX screenY width height sizemode">
 <head>
   <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
+    <link rel="localization" href="toolkit/main-window/editmenu.ftl"/>
+    <link rel="stylesheet" href="chrome://global/skin/"/>
     <link rel="stylesheet" href="chrome://devtools/skin/widgets.css"/>
     <link rel="stylesheet" href="chrome://devtools/skin/webconsole.css"/>
     <link rel="stylesheet" href="chrome://devtools/skin/components-frame.css"/>
     <link rel="stylesheet" href="resource://devtools/client/shared/components/reps/reps.css"/>
     <link rel="stylesheet" href="resource://devtools/client/shared/components/tabs/Tabs.css"/>
     <link rel="stylesheet" href="resource://devtools/client/shared/components/tabs/TabBar.css"/>
     <link rel="stylesheet" href="resource://devtools/client/shared/components/NotificationBox.css"/>
     <link rel="stylesheet" href="chrome://devtools/content/netmonitor/src/assets/styles/httpi.css"/>
 
+    <script type="text/javascript" src="chrome://global/content/l10n.js"></script>
     <script src="chrome://devtools/content/shared/theme-switching.js"></script>
     <script type="application/javascript"
             src="resource://devtools/client/webconsole/main.js"></script>
   </head>
   <body class="theme-sidebar" role="application">
     <div id="app-wrapper" class="theme-body">
       <div id="output-container" role="document" aria-live="polite"></div>
     </div>
--- a/devtools/client/webconsole/test/mochitest/browser_console_context_menu_entries.js
+++ b/devtools/client/webconsole/test/mochitest/browser_console_context_menu_entries.js
@@ -6,16 +6,19 @@
 // Check that we display the expected context menu entries.
 
 const TEST_URI = "http://example.com/browser/devtools/client/webconsole/" +
                  "test/mochitest/test-console.html";
 
 add_task(async function() {
   // Enable net messages in the console for this test.
   await pushPref("devtools.browserconsole.filter.net", true);
+  // These are required for testing the text input in the browser console:
+  await pushPref("devtools.browserconsole.html", true);
+  await pushPref("devtools.chrome.enabled", true);
 
   await addTab(TEST_URI);
   const hud = await HUDService.toggleBrowserConsole();
 
   info("Reload the content window to produce a network log");
   const onNetworkMessage = waitForMessage(hud, "test-console.html");
   ContentTask.spawn(gBrowser.selectedBrowser, null, () => {
     content.wrappedJSObject.location.reload();
@@ -51,27 +54,49 @@ add_task(async function() {
     "#console-menu-store (S) [disabled]",
     "#console-menu-copy (C)",
     "#console-menu-copy-object (o) [disabled]",
     "#console-menu-select (A)",
   ]);
   is(getSimplifiedContextMenu(menuPopup).join("\n"), expectedContextMenu.join("\n"),
     "The context menu has the expected entries for a simple log message");
 
+  menuPopup = await openContextMenu(hud, hud.jsterm.inputNode);
+
+  expectedContextMenu = [
+    "#editmenu-undo (editmenu-undo) [disabled]",
+    "#editmenu-cut (editmenu-cut)",
+    "#editmenu-copy (editmenu-copy)",
+    "#editmenu-paste (editmenu-paste)",
+    "#editmenu-delete (editmenu-delete) [disabled]",
+    "#editmenu-selectAll (editmenu-select-all) [disabled]",
+  ];
+  is(getL10NContextMenu(menuPopup).join("\n"), expectedContextMenu.join("\n"),
+    "The context menu has the correct edit menu items");
+
   await hideContextMenu(hud);
 });
 
 function addPrefBasedEntries(expectedEntries) {
   if (Services.prefs.getBoolPref("devtools.webconsole.sidebarToggle", false)) {
     expectedEntries.push("#console-menu-open-sidebar (V) [disabled]");
   }
 
   return expectedEntries;
 }
 
+function getL10NContextMenu(popupElement) {
+  return [...popupElement.querySelectorAll("menuitem")]
+    .map(entry => {
+      const l10nID = entry.getAttribute("data-l10n-id");
+      const disabled = entry.hasAttribute("disabled");
+      return `#${entry.id} (${l10nID})${disabled ? " [disabled]" : ""}`;
+    });
+}
+
 function getSimplifiedContextMenu(popupElement) {
   return [...popupElement.querySelectorAll("menuitem")]
     .map(entry => {
       const key = entry.getAttribute("accesskey");
       const disabled = entry.hasAttribute("disabled");
       return `#${entry.id} (${key})${disabled ? " [disabled]" : ""}`;
     });
 }
--- a/devtools/client/webconsole/utils/context-menu.js
+++ b/devtools/client/webconsole/utils/context-menu.js
@@ -176,8 +176,83 @@ function createContextMenu(hud, parentNo
       click: () => openSidebar(message.messageId),
     }));
   }
 
   return menu;
 }
 
 exports.createContextMenu = createContextMenu;
+
+/**
+ * Return an 'edit' menu for a input field. This integrates directly
+ * with docshell commands to provide the right enabled state and editor
+ * functionality.
+ *
+ * You'll need to call menu.popup() yourself, this just returns the Menu instance.
+ *
+ * @returns {Menu}
+ */
+function createEditContextMenu() {
+  const docshell = window.QueryInterface(Ci.nsIInterfaceRequestor)
+                         .getInterface(Ci.nsIWebNavigation)
+                         .QueryInterface(Ci.nsIDocShell);
+  const menu = new Menu({
+    id: "webconsole-menu"
+  });
+  menu.append(new MenuItem({
+    id: "editmenu-undo",
+    l10nID: "editmenu-undo",
+    disabled: !docshell.isCommandEnabled("cmd_undo"),
+    click: () => {
+      docshell.doCommand("cmd_undo");
+    },
+  }));
+  menu.append(new MenuItem({
+    type: "separator"
+  }));
+  menu.append(new MenuItem({
+    id: "editmenu-cut",
+    l10nID: "editmenu-cut",
+    disabled: !docshell.isCommandEnabled("cmd_cut"),
+    click: () => {
+      docshell.doCommand("cmd_cut");
+    },
+  }));
+  menu.append(new MenuItem({
+    id: "editmenu-copy",
+    l10nID: "editmenu-copy",
+    disabled: !docshell.isCommandEnabled("cmd_copy"),
+    click: () => {
+      docshell.doCommand("cmd_copy");
+    },
+  }));
+  menu.append(new MenuItem({
+    id: "editmenu-paste",
+    l10nID: "editmenu-paste",
+    disabled: !docshell.isCommandEnabled("cmd_paste"),
+    click: () => {
+      docshell.doCommand("cmd_paste");
+    },
+  }));
+  menu.append(new MenuItem({
+    id: "editmenu-delete",
+    l10nID: "editmenu-delete",
+    disabled: !docshell.isCommandEnabled("cmd_delete"),
+    click: () => {
+      docshell.doCommand("cmd_delete");
+    },
+  }));
+  menu.append(new MenuItem({
+    type: "separator"
+  }));
+  menu.append(new MenuItem({
+    id: "editmenu-selectAll",
+    l10nID: "editmenu-select-all",
+    disabled: !docshell.isCommandEnabled("cmd_selectAll"),
+    click: () => {
+      docshell.doCommand("cmd_selectAll");
+    },
+  }));
+  return menu;
+}
+
+exports.createEditContextMenu = createEditContextMenu;
--- a/devtools/client/webconsole/webconsole-output-wrapper.js
+++ b/devtools/client/webconsole/webconsole-output-wrapper.js
@@ -3,17 +3,17 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 "use strict";
 
 const { createElement, createFactory } = require("devtools/client/shared/vendor/react");
 const ReactDOM = require("devtools/client/shared/vendor/react-dom");
 const { Provider } = require("devtools/client/shared/vendor/react-redux");
 
 const actions = require("devtools/client/webconsole/actions/index");
-const { createContextMenu } = require("devtools/client/webconsole/utils/context-menu");
+const { createContextMenu, createEditContextMenu } = require("devtools/client/webconsole/utils/context-menu");
 const { configureStore } = require("devtools/client/webconsole/store");
 const { isPacketPrivate } = require("devtools/client/webconsole/utils/messages");
 const { getAllMessagesById, getMessage } = require("devtools/client/webconsole/selectors/messages");
 const Telemetry = require("devtools/client/shared/telemetry");
 
 const EventEmitter = require("devtools/shared/event-emitter");
 const App = createFactory(require("devtools/client/webconsole/components/App"));
 
@@ -155,16 +155,26 @@ WebConsoleOutputWrapper.prototype = {
 
         // Emit the "menu-open" event for testing.
         menu.once("open", () => this.emit("menu-open"));
         menu.popup(screenX, screenY, { doc: this.owner.chromeWindow.document });
 
         return menu;
       };
 
+      serviceContainer.openEditContextMenu = (e) => {
+        const { screenX, screenY } = e;
+        const menu = createEditContextMenu();
+        // Emit the "menu-open" event for testing.
+        menu.once("open", () => this.emit("menu-open"));
+        menu.popup(screenX, screenY, { doc: this.owner.chromeWindow.document });
+
+        return menu;
+      };
+
       if (this.toolbox) {
         Object.assign(serviceContainer, {
           onViewSourceInDebugger: frame => {
             this.toolbox.viewSourceInDebugger(frame.url, frame.line).then(() => {
               this.telemetry.recordEvent("devtools.main", "jump_to_source", "webconsole",
                                          null, { "session_id": this.toolbox.sessionId }
               );
               this.hud.emit("source-in-debugger-opened");