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
--- 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");