Bug 1254544 - Show clipboard commands in the synced tabs filter context menu. r=markh
MozReview-Commit-ID: 9XTYrU0xp9E
--- a/browser/base/content/browser.xul
+++ b/browser/base/content/browser.xul
@@ -467,16 +467,43 @@
<menuitem label="&syncedTabs.context.bookmarkSingleTab.label;"
accesskey="&syncedTabs.context.bookmarkSingleTab.accesskey;"
id="syncedTabsBookmarkSelected"/>
<menuseparator/>
<menuitem label="&syncSyncNowItem.label;"
accesskey="&syncSyncNowItem.accesskey;"
id="syncedTabsRefresh"/>
</menupopup>
+ <menupopup id="SyncedTabsSidebarTabsFilterContext"
+ class="textbox-contextmenu">
+ <menuitem label="&undoCmd.label;"
+ accesskey="&undoCmd.accesskey;"
+ cmd="cmd_undo"/>
+ <menuseparator/>
+ <menuitem label="&cutCmd.label;"
+ accesskey="&cutCmd.accesskey;"
+ cmd="cmd_cut"/>
+ <menuitem label="©Cmd.label;"
+ accesskey="©Cmd.accesskey;"
+ cmd="cmd_copy"/>
+ <menuitem label="&pasteCmd.label;"
+ accesskey="&pasteCmd.accesskey;"
+ cmd="cmd_paste"/>
+ <menuitem label="&deleteCmd.label;"
+ accesskey="&deleteCmd.accesskey;"
+ cmd="cmd_delete"/>
+ <menuseparator/>
+ <menuitem label="&selectAllCmd.label;"
+ accesskey="&selectAllCmd.accesskey;"
+ cmd="cmd_selectAll"/>
+ <menuseparator/>
+ <menuitem label="&syncSyncNowItem.label;"
+ accesskey="&syncSyncNowItem.accesskey;"
+ id="syncedTabsRefreshFilter"/>
+ </menupopup>
</popupset>
#ifdef CAN_DRAW_IN_TITLEBAR
<vbox id="titlebar">
<hbox id="titlebar-content">
<spacer id="titlebar-spacer" flex="1"/>
<hbox id="titlebar-buttonbox-container">
#ifdef XP_WIN
--- a/browser/components/syncedtabs/TabListView.js
+++ b/browser/components/syncedtabs/TabListView.js
@@ -16,16 +16,20 @@ let log = Cu.import("resource://gre/modu
this.EXPORTED_SYMBOLS = [
"TabListView"
];
function getContextMenu(window) {
return getChromeWindow(window).document.getElementById("SyncedTabsSidebarContext");
}
+function getTabsFilterContextMenu(window) {
+ return getChromeWindow(window).document.getElementById("SyncedTabsSidebarTabsFilterContext");
+}
+
/*
* TabListView
*
* Given a state, this object will render the corresponding DOM.
* It maintains no state of it's own. It listens for DOM events
* and triggers actions that may cause the state to change and
* ultimately the view to rerender.
*/
@@ -325,56 +329,120 @@ TabListView.prototype = {
this.props.onFilterFocus();
},
onFilterBlur() {
this.props.onFilterBlur();
},
// Set up the custom context menu
_setupContextMenu() {
- this._handleContentContextMenu = event =>
- this.handleContentContextMenu(event);
- this._handleContentContextMenuCommand = event =>
- this.handleContentContextMenuCommand(event);
-
- Services.els.addSystemEventListener(this._window, "contextmenu", this._handleContentContextMenu, false);
- let menu = getContextMenu(this._window);
- menu.addEventListener("command", this._handleContentContextMenuCommand, true);
+ Services.els.addSystemEventListener(this._window, "contextmenu", this, false);
+ for (let getMenu of [getContextMenu, getTabsFilterContextMenu]) {
+ let menu = getMenu(this._window);
+ menu.addEventListener("popupshowing", this, true);
+ menu.addEventListener("command", this, true);
+ }
},
_teardownContextMenu() {
// Tear down context menu
- Services.els.removeSystemEventListener(this._window, "contextmenu", this._handleContentContextMenu, false);
- let menu = getContextMenu(this._window);
- menu.removeEventListener("command", this._handleContentContextMenuCommand, true);
+ Services.els.removeSystemEventListener(this._window, "contextmenu", this, false);
+ for (let getMenu of [getContextMenu, getTabsFilterContextMenu]) {
+ let menu = getMenu(this._window);
+ menu.removeEventListener("popupshowing", this, true);
+ menu.removeEventListener("command", this, true);
+ }
+ },
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "contextmenu":
+ this.handleContextMenu(event);
+ break;
+
+ case "popupshowing": {
+ if (event.target.getAttribute("id") == "SyncedTabsSidebarTabsFilterContext") {
+ this.handleTabsFilterContextMenuShown(event);
+ }
+ break;
+ }
+
+ case "command": {
+ let menu = event.target.closest("menupopup");
+ switch (menu.getAttribute("id")) {
+ case "SyncedTabsSidebarContext":
+ this.handleContentContextMenuCommand(event);
+ break;
+
+ case "SyncedTabsSidebarTabsFilterContext":
+ this.handleTabsFilterContextMenuCommand(event);
+ break;
+ }
+ break;
+ }
+ }
+ },
+
+ handleTabsFilterContextMenuShown(event) {
+ let document = event.target.ownerDocument;
+ let focusedElement = document.commandDispatcher.focusedElement;
+ if (focusedElement != this.tabsFilter) {
+ this.tabsFilter.focus();
+ }
+ for (let item of event.target.children) {
+ if (!item.hasAttribute("cmd")) {
+ continue;
+ }
+ let command = item.getAttribute("cmd");
+ let controller = document.commandDispatcher.getControllerForCommand(command);
+ if (controller.isCommandEnabled(command)) {
+ item.removeAttribute("disabled");
+ } else {
+ item.setAttribute("disabled", "true");
+ }
+ }
},
handleContentContextMenuCommand(event) {
let id = event.target.getAttribute("id");
switch (id) {
case "syncedTabsOpenSelected":
this.onOpenSelected(event);
break;
case "syncedTabsBookmarkSelected":
this.onBookmarkTab();
break;
case "syncedTabsRefresh":
+ case "syncedTabsRefreshFilter":
this.props.onSyncRefresh();
break;
}
},
- handleContentContextMenu(event) {
- let itemNode = this._findParentItemNode(event.target);
- if (itemNode) {
- this._selectRow(itemNode);
+ handleTabsFilterContextMenuCommand(event) {
+ let command = event.target.getAttribute("cmd");
+ let dispatcher = getChromeWindow(this._window).document.commandDispatcher;
+ let controller = dispatcher.focusedElement.controllers.getControllerForCommand(command);
+ controller.doCommand(command);
+ },
+
+ handleContextMenu(event) {
+ let menu;
+
+ if (event.target == this.tabsFilter) {
+ menu = getTabsFilterContextMenu(this._window);
+ } else {
+ let itemNode = this._findParentItemNode(event.target);
+ if (itemNode) {
+ this._selectRow(itemNode);
+ }
+ menu = getContextMenu(this._window);
+ this.adjustContextMenu(menu);
}
- let menu = getContextMenu(this._window);
- this.adjustContextMenu(menu);
menu.openPopupAtScreen(event.screenX, event.screenY, true, event);
},
adjustContextMenu(menu) {
let item = this.container.querySelector('.item.selected');
let showTabOptions = this._isTab(item);
let el = menu.firstChild;
--- a/browser/components/syncedtabs/test/browser/browser_sidebar_syncedtabslist.js
+++ b/browser/components/syncedtabs/test/browser/browser_sidebar_syncedtabslist.js
@@ -250,16 +250,88 @@ add_task(function* testSyncedTabsSidebar
yield syncedTabsDeckComponent.updatePanel();
selectedPanel = syncedTabsDeckComponent.container.querySelector(".sync-state.selected");
Assert.ok(selectedPanel.classList.contains("tabs-container"),
"tabs panel is selected");
});
add_task(testClean);
+add_task(function* testSyncedTabsSidebarContextMenu() {
+ yield SidebarUI.show('viewTabsSidebar');
+ let syncedTabsDeckComponent = window.SidebarUI.browser.contentWindow.syncedTabsDeckComponent;
+ let SyncedTabs = window.SidebarUI.browser.contentWindow.SyncedTabs;
+
+ Assert.ok(syncedTabsDeckComponent, "component exists");
+
+ originalSyncedTabsInternal = SyncedTabs._internal;
+ SyncedTabs._internal = {
+ isConfiguredToSyncTabs: true,
+ hasSyncedThisSession: true,
+ getTabClients() { return Promise.resolve([])},
+ syncTabs() {return Promise.resolve();},
+ };
+
+ sinon.stub(syncedTabsDeckComponent, "_accountStatus", ()=> Promise.resolve(true));
+ sinon.stub(SyncedTabs._internal, "getTabClients", ()=> Promise.resolve(Cu.cloneInto(FIXTURE, {})));
+
+ yield syncedTabsDeckComponent.updatePanel();
+ // This is a hacky way of waiting for the view to render. The view renders
+ // after the following promise (a different instance of which is triggered
+ // in updatePanel) resolves, so we wait for it here as well
+ yield syncedTabsDeckComponent.tabListComponent._store.getData();
+
+ info("Right-clicking the search box should show text-related actions");
+ let filterMenuItems = [
+ "menuitem[cmd=cmd_undo]",
+ "menuseparator",
+ // We don't check whether the commands are enabled due to platform
+ // differences. On OS X and Windows, "cut" and "copy" are always enabled
+ // for HTML inputs; on Linux, they're only enabled if text is selected.
+ "menuitem[cmd=cmd_cut]",
+ "menuitem[cmd=cmd_copy]",
+ "menuitem[cmd=cmd_paste]",
+ "menuitem[cmd=cmd_delete]",
+ "menuseparator",
+ "menuitem[cmd=cmd_selectAll]",
+ "menuseparator",
+ "menuitem#syncedTabsRefreshFilter",
+ ];
+ yield* testContextMenu(syncedTabsDeckComponent,
+ "#SyncedTabsSidebarTabsFilterContext",
+ ".tabsFilter",
+ filterMenuItems);
+
+ info("Right-clicking a tab should show additional actions");
+ let tabMenuItems = [
+ ["menuitem#syncedTabsOpenSelected", { hidden: false }],
+ ["menuitem#syncedTabsBookmarkSelected", { hidden: false }],
+ ["menuseparator", { hidden: false }],
+ ["menuitem#syncedTabsRefresh", { hidden: false }],
+ ];
+ yield* testContextMenu(syncedTabsDeckComponent,
+ "#SyncedTabsSidebarContext",
+ "#tab-7cqCr77ptzX3-0",
+ tabMenuItems);
+
+ info("Right-clicking a client shouldn't show any actions");
+ let sidebarMenuItems = [
+ ["menuitem#syncedTabsOpenSelected", { hidden: true }],
+ ["menuitem#syncedTabsBookmarkSelected", { hidden: true }],
+ ["menuseparator", { hidden: true }],
+ ["menuitem#syncedTabsRefresh", { hidden: false }],
+ ];
+ yield* testContextMenu(syncedTabsDeckComponent,
+ "#SyncedTabsSidebarContext",
+ "#item-OL3EJCsdb2JD",
+ sidebarMenuItems);
+});
+
+add_task(testClean);
+
function checkItem(node, item) {
Assert.ok(node.classList.contains("item"),
"Node should have .item class");
if (item.client) {
// tab items
Assert.equal(node.querySelector(".item-title").textContent, item.title,
"Node's title element's text should match item title");
Assert.ok(node.classList.contains("tab"),
@@ -274,8 +346,52 @@ function checkItem(node, item) {
"Node's title element's text should match client name");
Assert.ok(node.classList.contains("client"),
"Node should have .client class");
Assert.equal(node.dataset.id, item.id,
"Node's ID should match item ID");
}
}
+function* testContextMenu(syncedTabsDeckComponent, contextSelector, triggerSelector, menuSelectors) {
+ let contextMenu = document.querySelector(contextSelector);
+ let triggerElement = syncedTabsDeckComponent.container.querySelector(triggerSelector);
+
+ let promisePopupShown = BrowserTestUtils.waitForEvent(contextMenu, "popupshown");
+
+ let chromeWindow = triggerElement.ownerDocument.defaultView.top;
+ let rect = triggerElement.getBoundingClientRect();
+ let contentRect = chromeWindow.SidebarUI.browser.getBoundingClientRect();
+ // The offsets in `rect` are relative to the content window, but
+ // `synthesizeMouseAtPoint` calls `nsIDOMWindowUtils.sendMouseEvent`,
+ // which interprets the offsets relative to the containing *chrome* window.
+ // This means we need to account for the width and height of any elements
+ // outside the `browser` element, like `sidebarheader`.
+ let offsetX = contentRect.x + rect.x + (rect.width / 2);
+ let offsetY = contentRect.y + rect.y + (rect.height / 4);
+
+ yield EventUtils.synthesizeMouseAtPoint(offsetX, offsetY, {
+ type: "contextmenu",
+ button: 2,
+ }, chromeWindow);
+ yield promisePopupShown;
+ checkChildren(contextMenu, menuSelectors);
+
+ let promisePopupHidden = BrowserTestUtils.waitForEvent(contextMenu, "popuphidden");
+ contextMenu.hidePopup();
+ yield promisePopupHidden;
+}
+
+function checkChildren(node, selectors) {
+ is(node.children.length, selectors.length, "Menu item count doesn't match");
+ for (let index = 0; index < node.children.length; index++) {
+ let child = node.children[index];
+ let [selector, props] = [].concat(selectors[index]);
+ ok(selector, `Node at ${index} should have selector`);
+ ok(child.matches(selector), `Node ${
+ index} should match ${selector}`);
+ if (props) {
+ Object.keys(props).forEach(prop => {
+ is(child[prop], props[prop], `${prop} value at ${index} should match`);
+ });
+ }
+ }
+}