Bug 1771831 - Make it possible to reorder tabs in "List all tabs" list by drag-and-drop. r=NeilDeakin

Also the tab list item can be dragged to the tab bar to reorder, or moved to
other window, or detached.

Differential Revision: https://phabricator.services.mozilla.com/D147709
This commit is contained in:
Tooru Fujisawa 2022-08-30 22:49:43 +00:00
Родитель 8a8963929a
Коммит d50c882977
8 изменённых файлов: 478 добавлений и 10 удалений

Просмотреть файл

@ -21,6 +21,9 @@
oncommand="PanelUI.showSubView('allTabsMenu-hiddenTabsView', this);"
data-l10n-id="all-tabs-menu-hidden-tabs"/>
<toolbarseparator id="allTabsMenu-tabsSeparator"/>
<vbox id="allTabsMenu-dropIndicatorHolder">
<vbox id="allTabsMenu-dropIndicator" collapsed="true"/>
</vbox>
<vbox id="allTabsMenu-allTabsViewTabs" class="panel-subview-body"/>
</vbox>
</panelview>

Просмотреть файл

@ -16,6 +16,7 @@ var gTabsPanel = {
allTabsButton: "alltabs-button",
allTabsView: "allTabsMenu-allTabsView",
allTabsViewTabs: "allTabsMenu-allTabsViewTabs",
dropIndicator: "allTabsMenu-dropIndicator",
containerTabsView: "allTabsMenu-containerTabsView",
hiddenTabsButton: "allTabsMenu-hiddenTabsButton",
hiddenTabsView: "allTabsMenu-hiddenTabsView",
@ -56,6 +57,7 @@ var gTabsPanel = {
containerNode: this.allTabsViewTabs,
filterFn: tab =>
!tab.hidden && (!tab.pinned || (showPinnedTabs && tab.pinned)),
dropIndicator: this.dropIndicator,
});
this.allTabsView.addEventListener("ViewShowing", e => {

Просмотреть файл

@ -511,6 +511,22 @@ toolbarpaletteitem[place="palette"] > #personal-bookmarks > #bookmarks-toolbar-b
position: absolute;
}
#allTabsMenu-dropIndicatorHolder {
display: block;
position: relative;
}
#allTabsMenu-dropIndicator {
background: url(chrome://browser/skin/tabbrowser/tab-drag-indicator.svg) no-repeat center;
display: block;
position: absolute;
transform: rotate(-90deg);
width: 12px;
height: 29px;
left: 8px;
top: 0;
}
#nav-bar-customization-target > #personal-bookmarks,
toolbar:not(#TabsToolbar) > #wrapper-personal-bookmarks,
toolbar:not(#TabsToolbar) > #personal-bookmarks {

Просмотреть файл

@ -409,6 +409,10 @@
return;
}
this.startTabDrag(event, tab);
}
startTabDrag(event, tab, { fromTabList = false } = {}) {
let selectedTabs = gBrowser.selectedTabs;
let otherSelectedTabs = selectedTabs.filter(
selectedTab => selectedTab != tab
@ -529,13 +533,14 @@
movingTabs: (tab.multiselected ? gBrowser.selectedTabs : [tab]).filter(
t => t.pinned == tab.pinned
),
fromTabList,
};
event.stopPropagation();
}
on_dragover(event) {
var effects = this._getDropEffectForTabDrag(event);
var effects = this.getDropEffectForTabDrag(event);
var ind = this._tabDropIndicator;
if (effects == "" || effects == "none") {
@ -571,7 +576,8 @@
let draggedTab = event.dataTransfer.mozGetDataAt(TAB_DROP_TYPE, 0);
if (
(effects == "move" || effects == "copy") &&
this == draggedTab.container
this == draggedTab.container &&
!draggedTab._dragData.fromTabList
) {
ind.hidden = true;
@ -692,9 +698,14 @@
newTranslateX -= tabWidth;
}
let dropIndex =
"animDropIndex" in draggedTab._dragData &&
draggedTab._dragData.animDropIndex;
let dropIndex;
if (draggedTab._dragData.fromTabList) {
dropIndex = this._getDropIndex(event, false);
} else {
dropIndex =
"animDropIndex" in draggedTab._dragData &&
draggedTab._dragData.animDropIndex;
}
let incrementDropIndex = true;
if (dropIndex && dropIndex > movingTabs[0]._tPos) {
dropIndex--;
@ -1982,7 +1993,7 @@
return tabs.length;
}
_getDropEffectForTabDrag(event) {
getDropEffectForTabDrag(event) {
var dt = event.dataTransfer;
let isMovingTabs = dt.mozItemCount > 0;

Просмотреть файл

@ -148,3 +148,4 @@ skip-if = (verify && os == 'mac')
[browser_bfcache_exemption_about_pages.js]
skip-if = !fission
[browser_tab_tooltips.js]
[browser_tab_manager_drag.js]

Просмотреть файл

@ -0,0 +1,226 @@
/**
* Test reordering the tabs in the Tab Manager, moving the tab between the
* Tab Manager and tab bar.
*/
"use strict";
const URL1 = "data:text/plain,tab1";
const URL2 = "data:text/plain,tab2";
const URL3 = "data:text/plain,tab3";
const URL4 = "data:text/plain,tab4";
const URL5 = "data:text/plain,tab5";
function assertOrder(order, expected, message) {
is(
JSON.stringify(order),
JSON.stringify(expected),
`The order of the tabs ${message}`
);
}
function toIndex(url) {
const m = url.match(/^data:text\/plain,tab(\d)/);
if (m) {
return parseInt(m[1]);
}
return 0;
}
function getOrderOfList(list) {
return [...list.querySelectorAll("toolbaritem")].map(row => {
const url = row.firstElementChild.tab.linkedBrowser.currentURI.spec;
return toIndex(url);
});
}
function getOrderOfTabs(tabs) {
return tabs.map(tab => {
const url = tab.linkedBrowser.currentURI.spec;
return toIndex(url);
});
}
async function testWithNewWindow(func) {
Services.prefs.setBoolPref("browser.tabs.tabmanager.enabled", true);
const newWindow = await BrowserTestUtils.openNewWindowWithFlushedCacheForMozSupports();
await Promise.all([
addTabTo(newWindow.gBrowser, URL1),
addTabTo(newWindow.gBrowser, URL2),
addTabTo(newWindow.gBrowser, URL3),
addTabTo(newWindow.gBrowser, URL4),
addTabTo(newWindow.gBrowser, URL5),
]);
newWindow.gTabsPanel.init();
const button = newWindow.document.getElementById("alltabs-button");
const allTabsView = newWindow.document.getElementById(
"allTabsMenu-allTabsView"
);
const allTabsPopupShownPromise = BrowserTestUtils.waitForEvent(
allTabsView,
"ViewShown"
);
button.click();
await allTabsPopupShownPromise;
await func(newWindow);
await BrowserTestUtils.closeWindow(newWindow);
Services.prefs.clearUserPref("browser.tabs.tabmanager.enabled");
}
add_task(async function test_reorder() {
await testWithNewWindow(async function(newWindow) {
const list = newWindow.document.getElementById(
"allTabsMenu-allTabsViewTabs"
);
assertOrder(getOrderOfList(list), [0, 1, 2, 3, 4, 5], "before reorder");
let rows;
rows = list.querySelectorAll("toolbaritem");
EventUtils.synthesizeDrop(
rows[3],
rows[1],
null,
"move",
newWindow,
newWindow,
{ clientX: 0, clientY: 0 }
);
assertOrder(getOrderOfList(list), [0, 3, 1, 2, 4, 5], "after moving up");
rows = list.querySelectorAll("toolbaritem");
EventUtils.synthesizeDrop(
rows[1],
rows[5],
null,
"move",
newWindow,
newWindow,
{ clientX: 0, clientY: 0 }
);
assertOrder(getOrderOfList(list), [0, 1, 2, 4, 3, 5], "after moving down");
rows = list.querySelectorAll("toolbaritem");
EventUtils.synthesizeDrop(
rows[4],
rows[3],
null,
"move",
newWindow,
newWindow,
{ clientX: 0, clientY: 0 }
);
assertOrder(
getOrderOfList(list),
[0, 1, 2, 3, 4, 5],
"after moving up again"
);
});
});
function tabOf(row) {
return row.firstElementChild.tab;
}
add_task(async function test_move_to_tab_bar() {
await testWithNewWindow(async function(newWindow) {
const list = newWindow.document.getElementById(
"allTabsMenu-allTabsViewTabs"
);
assertOrder(getOrderOfList(list), [0, 1, 2, 3, 4, 5], "before reorder");
let rows;
rows = list.querySelectorAll("toolbaritem");
EventUtils.synthesizeDrop(
rows[3],
tabOf(rows[1]),
null,
"move",
newWindow,
newWindow,
{ clientX: 0, clientY: 0 }
);
assertOrder(
getOrderOfList(list),
[0, 3, 1, 2, 4, 5],
"after moving up with tab bar"
);
rows = list.querySelectorAll("toolbaritem");
EventUtils.synthesizeDrop(
rows[1],
tabOf(rows[4]),
null,
"move",
newWindow,
newWindow,
{ clientX: 0, clientY: 0 }
);
assertOrder(
getOrderOfList(list),
[0, 1, 2, 3, 4, 5],
"after moving down with tab bar"
);
});
});
add_task(async function test_move_to_different_tab_bar() {
const newWindow2 = await BrowserTestUtils.openNewWindowWithFlushedCacheForMozSupports();
await testWithNewWindow(async function(newWindow) {
const list = newWindow.document.getElementById(
"allTabsMenu-allTabsViewTabs"
);
assertOrder(
getOrderOfList(list),
[0, 1, 2, 3, 4, 5],
"before reorder in newWindow"
);
assertOrder(
getOrderOfTabs(newWindow2.gBrowser.tabs),
[0],
"before reorder in newWindow2"
);
let rows;
rows = list.querySelectorAll("toolbaritem");
EventUtils.synthesizeDrop(
rows[3],
newWindow2.gBrowser.tabs[0],
null,
"move",
newWindow,
newWindow2,
{ clientX: 0, clientY: 0 }
);
assertOrder(
getOrderOfList(list),
[0, 1, 2, 4, 5],
"after moving to other window in newWindow"
);
assertOrder(
getOrderOfTabs(newWindow2.gBrowser.tabs),
[3, 0],
"after moving to other window in newWindow2"
);
});
await BrowserTestUtils.closeWindow(newWindow2);
});

Просмотреть файл

@ -26,10 +26,18 @@ function triggerClickOn(target, options) {
return promise;
}
async function addTab(url = "http://mochi.test:8888/", params = {}) {
async function addTab(url = "http://mochi.test:8888/", params) {
return addTabTo(gBrowser, url, params);
}
async function addTabTo(
targetBrowser,
url = "http://mochi.test:8888/",
params = {}
) {
params.skipAnimation = true;
const tab = BrowserTestUtils.addTab(gBrowser, url, params);
const browser = gBrowser.getBrowserForTab(tab);
const tab = BrowserTestUtils.addTab(targetBrowser, url, params);
const browser = targetBrowser.getBrowserForTab(tab);
await BrowserTestUtils.browserLoaded(browser);
return tab;
}

Просмотреть файл

@ -14,6 +14,8 @@ ChromeUtils.defineModuleGetter(
var EXPORTED_SYMBOLS = ["TabsPanel"];
const TAB_DROP_TYPE = "application/x-moz-tabbrowser-tab";
function setAttributes(element, attrs) {
for (let [name, value] of Object.entries(attrs)) {
if (value) {
@ -25,11 +27,23 @@ function setAttributes(element, attrs) {
}
class TabsListBase {
constructor({ className, filterFn, insertBefore, containerNode }) {
constructor({
className,
filterFn,
insertBefore,
containerNode,
dropIndicator = null,
}) {
this.className = className;
this.filterFn = filterFn;
this.insertBefore = insertBefore;
this.containerNode = containerNode;
this.dropIndicator = dropIndicator;
if (this.dropIndicator) {
this.dropTargetRow = null;
this.dropTargetDirection = 0;
}
this.doc = containerNode.ownerDocument;
this.gBrowser = this.doc.defaultView.gBrowser;
@ -60,6 +74,21 @@ class TabsListBase {
case "command":
this._selectTab(event.target.tab);
break;
case "dragstart":
this._onDragStart(event);
break;
case "dragover":
this._onDragOver(event);
break;
case "dragleave":
this._onDragLeave(event);
break;
case "dragend":
this._onDragEnd(event);
break;
case "drop":
this._onDrop(event);
break;
}
}
@ -104,10 +133,19 @@ class TabsListBase {
_setupListeners() {
this.listenersRegistered = true;
this.gBrowser.tabContainer.addEventListener("TabAttrModified", this);
this.gBrowser.tabContainer.addEventListener("TabClose", this);
this.gBrowser.tabContainer.addEventListener("TabMove", this);
this.gBrowser.tabContainer.addEventListener("TabPinned", this);
if (this.dropIndicator) {
this.containerNode.addEventListener("dragstart", this);
this.containerNode.addEventListener("dragover", this);
this.containerNode.addEventListener("dragleave", this);
this.containerNode.addEventListener("dragend", this);
this.containerNode.addEventListener("drop", this);
}
}
_cleanupListeners() {
@ -115,6 +153,15 @@ class TabsListBase {
this.gBrowser.tabContainer.removeEventListener("TabClose", this);
this.gBrowser.tabContainer.removeEventListener("TabMove", this);
this.gBrowser.tabContainer.removeEventListener("TabPinned", this);
if (this.dropIndicator) {
this.containerNode.removeEventListener("dragstart", this);
this.containerNode.removeEventListener("dragover", this);
this.containerNode.removeEventListener("dragleave", this);
this.containerNode.removeEventListener("dragend", this);
this.containerNode.removeEventListener("drop", this);
}
this.listenersRegistered = false;
}
@ -317,4 +364,158 @@ class TabsPanel extends TabsListBase {
}
}
}
_onDragStart(event) {
const row = this._getDragTargetRow(event);
if (!row) {
return;
}
this.gBrowser.tabContainer.startTabDrag(event, row.firstElementChild.tab, {
fromTabList: true,
});
}
_getDragTargetRow(event) {
let row = event.target;
while (row && row.localName !== "toolbaritem") {
row = row.parentNode;
}
return row;
}
_isMovingTabs(event) {
var effects = this.gBrowser.tabContainer.getDropEffectForTabDrag(event);
return effects == "move";
}
_onDragOver(event) {
if (!this._isMovingTabs(event)) {
return;
}
if (!this._updateDropTarget(event)) {
return;
}
event.preventDefault();
event.stopPropagation();
}
_getRowIndex(row) {
return Array.prototype.indexOf.call(this.containerNode.children, row);
}
_onDrop(event) {
if (!this._isMovingTabs(event)) {
return;
}
if (!this._updateDropTarget(event)) {
return;
}
event.preventDefault();
event.stopPropagation();
let draggedTab = event.dataTransfer.mozGetDataAt(TAB_DROP_TYPE, 0);
if (draggedTab === this.dropTargetRow.firstElementChild.tab) {
this._cleanupDragDetails();
return;
}
const targetTab = this.dropTargetRow.firstElementChild.tab;
// NOTE: Given the list is opened only when the window is focused,
// we don't have to check `draggedTab.container`.
let pos;
if (draggedTab._tPos < targetTab._tPos) {
pos = targetTab._tPos + this.dropTargetDirection;
} else {
pos = targetTab._tPos + this.dropTargetDirection + 1;
}
this.gBrowser.moveTabTo(draggedTab, pos);
this._cleanupDragDetails();
}
_onDragLeave(event) {
if (!this._isMovingTabs(event)) {
return;
}
if (event.target !== this.containerNode) {
return;
}
this._clearDropTarget();
}
_onDragEnd(event) {
if (!this._isMovingTabs(event)) {
return;
}
this._cleanupDragDetails();
}
_updateDropTarget(event) {
const row = this._getDragTargetRow(event);
if (!row) {
return false;
}
const rect = row.getBoundingClientRect();
const index = this._getRowIndex(row);
if (index === -1) {
return false;
}
const threshold = rect.height * 0.5;
if (event.clientY < rect.top + threshold) {
this._setDropTarget(row, -1);
} else {
this._setDropTarget(row, 0);
}
return true;
}
_setDropTarget(row, direction) {
this._clearDropTarget();
this.dropTargetRow = row;
this.dropTargetDirection = direction;
let offset = this.dropIndicator.parentNode.getBoundingClientRect().top;
let top;
if (this.dropTargetDirection === -1) {
if (this.dropTargetRow.previousSibling) {
const rect = this.dropTargetRow.previousSibling.getBoundingClientRect();
top = rect.top + rect.height;
} else {
const rect = this.dropTargetRow.getBoundingClientRect();
top = rect.top;
}
} else {
const rect = this.dropTargetRow.getBoundingClientRect();
top = rect.top + rect.height;
}
this.dropIndicator.style.top = `${top - offset - 12}px`;
this.dropIndicator.collapsed = false;
}
_clearDropTarget() {
if (this.dropTargetRow) {
this.dropTargetRow = null;
}
this.dropIndicator.collapsed = true;
}
_cleanupDragDetails() {
this._clearDropTarget();
}
}