Bug 1458010 - Add ability to select multiple tabs using Ctrl/Cmd. r?jaws
MozReview-Commit-ID: BHelQhtv7Gk
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -450,16 +450,17 @@ pref("browser.link.open_newwindow.restri
// different.
#ifdef XP_MACOSX
pref("browser.link.open_newwindow.disabled_in_fullscreen", true);
#else
pref("browser.link.open_newwindow.disabled_in_fullscreen", false);
#endif
// Tabbed browser
+pref("browser.tabs.multiselect", false);
pref("browser.tabs.20FpsThrobber", false);
pref("browser.tabs.30FpsThrobber", false);
pref("browser.tabs.closeTabByDblclick", false);
pref("browser.tabs.closeWindowWithLastTab", true);
// Open related links to a tab, e.g., link in current tab, at next to the
// current tab if |insertRelatedAfterCurrent| is true. Otherwise, always
// append new tab to the end.
pref("browser.tabs.insertRelatedAfterCurrent", true);
--- a/browser/base/content/tabbrowser.css
+++ b/browser/base/content/tabbrowser.css
@@ -31,16 +31,20 @@
.tab-icon-overlay[crashed] {
display: -moz-box;
}
.tab-label {
white-space: nowrap;
}
+.tab-label[multiselected] {
+ font-weight: bold;
+}
+
.tab-label-container {
overflow: hidden;
}
.tab-label-container[pinned] {
width: 0;
}
--- a/browser/base/content/tabbrowser.js
+++ b/browser/base/content/tabbrowser.js
@@ -129,16 +129,18 @@ window._gBrowser = {
"resumeMedia", "mute", "unmute", "blockedPopups", "lastURI",
"purgeSessionHistory", "stopScroll", "startScroll",
"userTypedValue", "userTypedClear", "mediaBlocked",
"didStartLoadSinceLastUserTyping", "audioMuted"
],
_removingTabs: [],
+ _multiSelectedTabsMap: new WeakMap(),
+
/**
* Tab close requests are ignored if the window is closing anyway,
* e.g. when holding Ctrl+W.
*/
_windowIsClosing: false,
/**
* This defines a proxy which allows us to access browsers by
@@ -3561,16 +3563,49 @@ window._gBrowser = {
* Can be from a different window as well
* @param aRestoreTabImmediately
* Can defer loading of the tab contents
*/
duplicateTab(aTab, aRestoreTabImmediately) {
return SessionStore.duplicateTab(window, aTab, 0, aRestoreTabImmediately);
},
+ addToMultiSelectedTabs(aTab) {
+ if (aTab.multiselected) {
+ return;
+ }
+
+ aTab.setAttribute("multiselected", "true");
+ this._multiSelectedTabsMap.set(aTab, null);
+ },
+
+ removeFromMultiSelectedTabs(aTab) {
+ if (!aTab.multiselected) {
+ return;
+ }
+ aTab.removeAttribute("multiselected");
+ this._multiSelectedTabsMap.delete(aTab);
+ },
+
+ clearMultiSelectedTabs() {
+ const selectedTabs = ChromeUtils.nondeterministicGetWeakMapKeys(this._multiSelectedTabsMap);
+ for (let tab of selectedTabs) {
+ if (tab.isConnected && tab.multiselected) {
+ tab.removeAttribute("multiselected");
+ }
+ }
+ this._multiSelectedTabsMap = new WeakMap();
+ },
+
+ multiSelectedTabsCount() {
+ return ChromeUtils.nondeterministicGetWeakMapKeys(this._multiSelectedTabsMap)
+ .filter(tab => tab.isConnected)
+ .length;
+ },
+
activateBrowserForPrintPreview(aBrowser) {
this._printPreviewBrowsers.add(aBrowser);
if (this._switcher) {
this._switcher.activateBrowserForPrintPreview(aBrowser);
}
aBrowser.docShellIsActive = true;
},
--- a/browser/base/content/tabbrowser.xml
+++ b/browser/base/content/tabbrowser.xml
@@ -1576,17 +1576,17 @@
class="tab-icon-overlay"
role="presentation"/>
<xul:hbox class="tab-label-container"
xbl:inherits="pinned,selected=visuallyselected,labeldirection"
onoverflow="this.setAttribute('textoverflow', 'true');"
onunderflow="this.removeAttribute('textoverflow');"
flex="1">
<xul:label class="tab-text tab-label"
- xbl:inherits="xbl:text=label,accesskey,fadein,pinned,selected=visuallyselected,attention"
+ xbl:inherits="xbl:text=label,accesskey,fadein,pinned,selected=visuallyselected,attention,multiselected"
role="presentation"/>
</xul:hbox>
<xul:image xbl:inherits="soundplaying,soundplaying-scheduledremoval,pinned,muted,blocked,selected=visuallyselected,activemedia-blocked"
anonid="soundplaying-icon"
class="tab-icon-sound"
role="presentation"/>
<xul:image anonid="close-button"
xbl:inherits="fadein,pinned,selected=visuallyselected"
@@ -1657,16 +1657,21 @@
return this.getAttribute("hidden") == "true";
</getter>
</property>
<property name="muted" readonly="true">
<getter>
return this.getAttribute("muted") == "true";
</getter>
</property>
+ <property name="multiselected" readonly="true">
+ <getter>
+ return this.getAttribute("multiselected") == "true";
+ </getter>
+ </property>
<!--
Describes how the tab ended up in this mute state. May be any of:
- undefined: The tabs mute state has never changed.
- null: The mute state was last changed through the UI.
- Any string: The ID was changed through an extension API. The string
must be the ID of the extension which changed it.
-->
@@ -1953,28 +1958,50 @@
if (tabContainer._closeTabByDblclick &&
event.button == 0 &&
event.detail == 1) {
this._selectedOnFirstMouseDown = this.selected;
}
if (this.selected) {
this.style.MozUserFocus = "ignore";
- } else if (this.mOverCloseButton ||
- this._overPlayingIcon) {
- // Prevent tabbox.xml from selecting the tab.
- event.stopPropagation();
+ } else {
+ // When browser.tabs.multiselect config is set to false,
+ // then we ignore the state of multi-selection keys (Ctrl/Cmd).
+ const tabSelectionToggled = Services.prefs.getBoolPref("browser.tabs.multiselect") &&
+ event.getModifierState("Accel");
+
+ if (this.mOverCloseButton || this._overPlayingIcon || tabSelectionToggled) {
+ // Prevent tabbox.xml from selecting the tab.
+ event.stopPropagation();
+ }
}
]]>
</handler>
<handler event="mouseup">
this.style.MozUserFocus = "";
</handler>
<handler event="click" button="0"><![CDATA[
+ if (Services.prefs.getBoolPref("browser.tabs.multiselect")) {
+ const tabSelectionToggled = event.getModifierState("Accel");
+ if (tabSelectionToggled) {
+ if (this.multiselected) {
+ gBrowser.removeFromMultiSelectedTabs(this);
+ } else {
+ gBrowser.addToMultiSelectedTabs(this);
+ }
+ return;
+ } else if (gBrowser.multiSelectedTabsCount() > 0) {
+ // Tabs were previously multi-selected and user clicks on a tab
+ // without holding Ctrl/Cmd Key
+ gBrowser.clearMultiSelectedTabs();
+ }
+ }
+
if (this._overPlayingIcon) {
this.toggleMuteAudio();
return;
}
if (event.originalTarget.getAttribute("anonid") == "close-button") {
gBrowser.removeTab(this, {
animate: true,
--- a/browser/base/content/test/tabs/browser.ini
+++ b/browser/base/content/test/tabs/browser.ini
@@ -35,8 +35,9 @@ support-files = file_new_tab_page.html
skip-if = (debug && os == 'mac') || (debug && os == 'linux' && bits == 64) #Bug 1421183, disabled on Linux/OSX for leaked windows
[browser_tabReorder_overflow.js]
[browser_tabswitch_updatecommands.js]
[browser_viewsource_of_data_URI_in_file_process.js]
[browser_visibleTabs_bookmarkAllTabs.js]
[browser_visibleTabs_contextMenu.js]
[browser_open_newtab_start_observer_notification.js]
[browser_bug_1387976_restore_lazy_tab_browser_muted_state.js]
+[browser_multiselect_tabs_using_Ctrl.js]
new file mode 100644
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_multiselect_tabs_using_Ctrl.js
@@ -0,0 +1,89 @@
+const PREF_MULTISELECT_TABS = "browser.tabs.multiselect";
+
+async function triggerClickOn(target, options) {
+ let promise = BrowserTestUtils.waitForEvent(target, "click");
+ if (AppConstants.platform == "macosx") {
+ options = { metaKey: options.ctrlKey };
+ }
+ EventUtils.synthesizeMouseAtCenter(target, options);
+ return promise;
+}
+
+async function addTab() {
+ const tab = BrowserTestUtils.addTab(gBrowser, "http://mochi.test:8888/");
+ const browser = gBrowser.getBrowserForTab(tab);
+ await BrowserTestUtils.browserLoaded(browser);
+ return tab;
+}
+
+add_task(async function clickWithoutPrefSet() {
+ let tab = await addTab();
+ let mSelectedTabs = gBrowser._multiSelectedTabsMap;
+
+ isnot(gBrowser.selectedTab, tab, "Tab doesn't have focus");
+
+ await triggerClickOn(tab, { ctrlKey: true });
+
+ ok(!tab.multiselected && !mSelectedTabs.has(tab),
+ "Multi-select tab doesn't work when multi-select pref is not set");
+ is(gBrowser.selectedTab, tab,
+ "Tab has focus, selected tab has changed after Ctrl/Cmd + click");
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function clickWithPrefSet() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [PREF_MULTISELECT_TABS, true]
+ ]
+ });
+
+ let mSelectedTabs = gBrowser._multiSelectedTabsMap;
+ const initialFocusedTab = gBrowser.selectedTab;
+ const tab = await addTab();
+
+ await triggerClickOn(tab, { ctrlKey: true });
+ ok(tab.multiselected && mSelectedTabs.has(tab), "Tab should be (multi) selected after click");
+ isnot(gBrowser.selectedTab, tab, "Multi-selected tab is not focused");
+ is(gBrowser.selectedTab, initialFocusedTab, "Focused tab doesn't change");
+
+ await triggerClickOn(tab, { ctrlKey: true });
+ ok(!tab.multiselected && !mSelectedTabs.has(tab), "Tab is not selected anymore");
+ is(gBrowser.selectedTab, initialFocusedTab, "Focused tab still doesn't change");
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function clearSelection() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [PREF_MULTISELECT_TABS, true]
+ ]
+ });
+
+ const tab1 = await addTab();
+ const tab2 = await addTab();
+ const tab3 = await addTab();
+
+ info("We select tab1 and tab2 with ctrl key down");
+ await triggerClickOn(tab1, { ctrlKey: true });
+ await triggerClickOn(tab2, { ctrlKey: true });
+
+ ok(tab1.multiselected && gBrowser._multiSelectedTabsMap.has(tab1), "Tab1 is (multi) selected");
+ ok(tab2.multiselected && gBrowser._multiSelectedTabsMap.has(tab2), "Tab2 is (multi) selected");
+ is(gBrowser.multiSelectedTabsCount(), 2, "Two tabs selected");
+ isnot(tab3, gBrowser.selectedTab, "Tab3 doesn't have focus");
+
+ info("We select tab3 with Ctrl key up");
+ await triggerClickOn(tab3, { ctrlKey: false });
+
+ ok(!tab1.multiselected, "Tab1 is unselected");
+ ok(!tab2.multiselected, "Tab2 is unselected");
+ is(gBrowser.multiSelectedTabsCount(), 0, "Selection is cleared");
+ is(tab3, gBrowser.selectedTab, "Tab3 has focus");
+
+ BrowserTestUtils.removeTab(tab1);
+ BrowserTestUtils.removeTab(tab2);
+ BrowserTestUtils.removeTab(tab3);
+});