Bug 1458010 - Add ability to select multiple tabs using Ctrl/Cmd. r?jaws draft multiselect_ctrl_key
authorlayely <ablayelyfondou@gmail.com>
Sat, 05 May 2018 03:56:23 +0000
branchmultiselect_ctrl_key
changeset 793275 a53ab6b3a7eed5db69a72f92f239ee8f958bd200
parent 791919 b1628ac71fcc15797baec6083650bfcde650f190
push id109330
push userbmo:ablayelyfondou@gmail.com
push dateWed, 09 May 2018 19:45:04 +0000
reviewersjaws
bugs1458010
milestone61.0a1
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
@@ -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);
+});