Bug 1458010 - Add ability to select multiple tabs using Ctrl/Cmd. r=jaws
authorlayely <ablayelyfondou@gmail.com>
Sat, 05 May 2018 03:56:23 +0000
changeset 417740 4edd0b64421735f31b8c678474829d042018cc36
parent 417739 731dfa211b3846b7c5aff134ecc097f83865a913
child 417741 c70d70b99eb6d88ae328ece989889de4f8ff44fc
push id33978
push userdluca@mozilla.com
push dateThu, 10 May 2018 21:54:47 +0000
treeherdermozilla-central@d302824da0ea [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersjaws
bugs1458010
milestone62.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1458010 - Add ability to select multiple tabs using Ctrl/Cmd. r=jaws MozReview-Commit-ID: BHelQhtv7Gk
browser/app/profile/firefox.js
browser/base/content/tabbrowser.css
browser/base/content/tabbrowser.js
browser/base/content/tabbrowser.xml
browser/base/content/test/tabs/browser.ini
browser/base/content/test/tabs/browser_multiselect_tabs_using_Ctrl.js
--- 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
@@ -3600,16 +3602,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);
+});