commit 46ab4c9b906cc0663886c38171c2801c8d764f7e
parent 44a92b3987da6b9fba2ff256d2b731ba12c9aead
Author: Nikki Sharpley <nsharpley@mozilla.com>
Date: Tue, 21 Oct 2025 18:01:12 +0000
Bug 1983124 - Stack dragged tabs when dragging multiple tabs r=tabbrowser-reviewers,desktop-theme-reviewers,sclements,sthompson
Differential Revision: https://phabricator.services.mozilla.com/D263949
Diffstat:
9 files changed, 1411 insertions(+), 15 deletions(-)
diff --git a/browser/app/profile/firefox.js b/browser/app/profile/firefox.js
@@ -1065,6 +1065,11 @@ pref("browser.tabs.dragDrop.expandGroup.delayMS", 350);
pref("browser.tabs.dragDrop.selectTab.delayMS", 350);
pref("browser.tabs.dragDrop.pinInteractionCue.delayMS", 500);
pref("browser.tabs.dragDrop.moveOverThresholdPercent", 80);
+#ifdef NIGHTLY_BUILD
+pref("browser.tabs.dragDrop.multiselectStacking", true);
+#else
+pref("browser.tabs.dragDrop.multiselectStacking", false);
+#endif
pref("browser.tabs.firefox-view.logLevel", "Warn");
diff --git a/browser/base/content/browser-main.js b/browser/base/content/browser-main.js
@@ -20,6 +20,7 @@
Services.scriptloader.loadSubScript("chrome://browser/content/browser-customtitlebar.js", this);
Services.scriptloader.loadSubScript("chrome://browser/content/browser-unified-extensions.js", this);
Services.scriptloader.loadSubScript("chrome://browser/content/tabbrowser/drag-and-drop.js", this);
+ Services.scriptloader.loadSubScript("chrome://browser/content/tabbrowser/tab-stacking.js", this);
Services.scriptloader.loadSubScript("chrome://browser/content/tabbrowser/tab.js", this);
Services.scriptloader.loadSubScript("chrome://browser/content/tabbrowser/tabbrowser.js", this);
Services.scriptloader.loadSubScript("chrome://browser/content/tabbrowser/tabgroup.js", this);
diff --git a/browser/components/tabbrowser/content/drag-and-drop.js b/browser/components/tabbrowser/content/drag-and-drop.js
@@ -118,12 +118,12 @@
!draggedTab._dragData.fromTabList
) {
ind.hidden = true;
-
if (this.#isAnimatingMoveTogetherSelectedTabs()) {
// Wait for moving selected tabs together animation to finish.
return;
}
this.finishMoveTogetherSelectedTabs(draggedTab);
+ this._updateTabStylesOnDrag(draggedTab, dropEffect);
if (dropEffect == "move") {
this.#setMovingTabMode(true);
@@ -491,6 +491,7 @@
}
} else {
moveTabs();
+ this._tabbrowserTabs._notifyBackgroundTab(movingTabs.at(-1));
}
}
} else if (isTabGroupLabel(draggedTab)) {
@@ -1133,13 +1134,11 @@
expandGroupOnDrop: collapseTabGroupDuringDrag,
};
if (this._rtlMode) {
- // Reverse order to handle positioning in `updateTabStylesOnDrag`
+ // Reverse order to handle positioning in `_updateTabStylesOnDrag`
// and animation in `_animateTabMove`
tab._dragData.movingTabs.reverse();
}
- this._updateTabStylesOnDrag(tab, event);
-
if (isMovingInTabStrip) {
this.#setMovingTabMode(true);
@@ -1167,7 +1166,13 @@
This function updates the position and widths of elements affected by this layout shift
when the tab is first selected to be dragged.
*/
- _updateTabStylesOnDrag(tab) {
+ _updateTabStylesOnDrag(tab, dropEffect) {
+ let tabStripItemElement = elementToMove(tab);
+ tabStripItemElement.style.pointerEvents =
+ dropEffect == "copy" ? "auto" : "";
+ if (tabStripItemElement.hasAttribute("dragtarget")) {
+ return;
+ }
let isPinned = tab.pinned;
let numPinned = gBrowser.pinnedTabCount;
let allTabs = this._tabbrowserTabs.ariaFocusableItems;
@@ -1224,7 +1229,9 @@
}
// Prevent flex rules from resizing non dragged tabs while the dragged
// tabs are positioned absolutely
- t.style.maxWidth = tabRect.width + "px";
+ if (tabRect.width) {
+ t.style.maxWidth = tabRect.width + "px";
+ }
// Prevent non-moving tab strip items from performing any animations
// at the very beginning of the drag operation; this prevents them
// from appearing to move while the dragged tabs are positioned absolutely
@@ -1249,7 +1256,6 @@
// Use .tab-group-label-container or .tabbrowser-tab for size/position
// calculations.
- let tabStripItemElement = elementToMove(tab);
let rect =
window.windowUtils.getBoundsWithoutFlushing(tabStripItemElement);
// Vertical tabs live under the #sidebar-main element which gets animated and has a
diff --git a/browser/components/tabbrowser/content/tab-stacking.js b/browser/components/tabbrowser/content/tab-stacking.js
@@ -0,0 +1,1245 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ const isTab = element => gBrowser.isTab(element);
+ const isTabGroupLabel = element => gBrowser.isTabGroupLabel(element);
+
+ /**
+ * The elements in the tab strip from `this.ariaFocusableItems` that contain
+ * logical information are:
+ *
+ * - <tab> (.tabbrowser-tab)
+ * - <tab-group> label element (.tab-group-label)
+ *
+ * The elements in the tab strip that contain the space inside of the <tabs>
+ * element are:
+ *
+ * - <tab> (.tabbrowser-tab)
+ * - <tab-group> label element wrapper (.tab-group-label-container)
+ *
+ * When working with tab strip items, if you need logical information, you
+ * can get it directly, e.g. `element.elementIndex` or `element._tPos`. If
+ * you need spatial information like position or dimensions, then you should
+ * call this function. For example, `elementToMove(element).getBoundingClientRect()`
+ * or `elementToMove(element).style.top`.
+ *
+ * @param {MozTabbrowserTab|typeof MozTabbrowserTabGroup.labelElement} element
+ * @returns {MozTabbrowserTab|vbox}
+ */
+ const elementToMove = element => {
+ if (isTab(element)) {
+ return element;
+ }
+ if (isTabGroupLabel(element)) {
+ return element.closest(".tab-group-label-container");
+ }
+ throw new Error(`Element "${element.tagName}" is not expected to move`);
+ };
+
+ window.TabStacking = class extends window.TabDragAndDrop {
+ constructor(tabbrowserTabs) {
+ super(tabbrowserTabs);
+ }
+
+ /**
+ * Move together all selected tabs around the tab in param.
+ */
+ _moveTogetherSelectedTabs(tab) {
+ let selectedTabs = gBrowser.selectedTabs;
+ let tabIndex = selectedTabs.indexOf(tab);
+ if (selectedTabs.some(t => t.pinned != tab.pinned)) {
+ throw new Error(
+ "Cannot move together a mix of pinned and unpinned tabs."
+ );
+ }
+ let isGrid = this._isContainerVerticalPinnedGrid(tab);
+ let animate = !gReduceMotion;
+
+ tab._moveTogetherSelectedTabsData = {
+ finished: !animate,
+ };
+
+ tab.toggleAttribute("multiselected-move-together", true);
+
+ let addAnimationData = movingTab => {
+ movingTab._moveTogetherSelectedTabsData = {
+ translateX: 0,
+ translateY: 0,
+ animate: true,
+ };
+ movingTab.toggleAttribute("multiselected-move-together", true);
+
+ let postTransitionCleanup = () => {
+ movingTab._moveTogetherSelectedTabsData.animate = false;
+ };
+ if (gReduceMotion) {
+ postTransitionCleanup();
+ } else {
+ let onTransitionEnd = transitionendEvent => {
+ if (
+ transitionendEvent.propertyName != "transform" ||
+ transitionendEvent.originalTarget != movingTab
+ ) {
+ return;
+ }
+ movingTab.removeEventListener("transitionend", onTransitionEnd);
+ postTransitionCleanup();
+ };
+
+ movingTab.addEventListener("transitionend", onTransitionEnd);
+ }
+
+ let tabRect = tab.getBoundingClientRect();
+ let movingTabRect = movingTab.getBoundingClientRect();
+ movingTab._moveTogetherSelectedTabsData.translateX =
+ tabRect.x - movingTabRect.x;
+ movingTab._moveTogetherSelectedTabsData.translateY =
+ tabRect.y - movingTabRect.y;
+ };
+
+ let selectedIndices = selectedTabs.map(t => t.elementIndex);
+ let currentIndex = 0;
+ let draggedRect = tab.getBoundingClientRect();
+ let translateX = 0;
+ let translateY = 0;
+
+ // The currentIndex represents the indexes for all visible tab strip items after the
+ // selected tabs have moved together. These values make the math in _animateTabMove and
+ // _animateExpandedPinnedTabMove possible and less prone to edge cases when dragging
+ // multiple tabs.
+ for (let unmovingTab of this._tabbrowserTabs.ariaFocusableItems) {
+ if (unmovingTab.multiselected) {
+ unmovingTab.currentIndex = tab.elementIndex;
+ // Skip because this multiselected tab should
+ // be shifted towards the dragged Tab.
+ continue;
+ }
+ if (unmovingTab.elementIndex > selectedIndices[currentIndex]) {
+ while (
+ selectedIndices[currentIndex + 1] &&
+ unmovingTab.elementIndex > selectedIndices[currentIndex + 1]
+ ) {
+ let currentRect = selectedTabs
+ .find(t => t.elementIndex == selectedIndices[currentIndex])
+ .getBoundingClientRect();
+ // For everything but the grid, we need to work out the shift required based
+ // on the size of the tabs being dragged together.
+ translateY -= currentRect.height;
+ translateX -= currentRect.width;
+ currentIndex++;
+ }
+
+ // Find the new index of the tab once selected tabs have moved together to use
+ // for positioning and animation
+ let isAfterDraggedTab =
+ unmovingTab.elementIndex - currentIndex > tab.elementIndex;
+ let newIndex = isAfterDraggedTab
+ ? unmovingTab.elementIndex - currentIndex
+ : unmovingTab.elementIndex - currentIndex - 1;
+ let newTranslateX = isAfterDraggedTab
+ ? translateX
+ : translateX - draggedRect.width;
+ let newTranslateY = isAfterDraggedTab
+ ? translateY
+ : translateY - draggedRect.height;
+ unmovingTab.currentIndex = newIndex;
+ unmovingTab._moveTogetherSelectedTabsData = {
+ translateX: 0,
+ translateY: 0,
+ };
+ if (isGrid) {
+ // For the grid, use the position of the tab with the old index to dictate the
+ // translation needed for the background tab with the new index to move there.
+ let unmovingTabRect = unmovingTab.getBoundingClientRect();
+ let oldTabRect =
+ this._tabbrowserTabs.ariaFocusableItems[
+ newIndex
+ ].getBoundingClientRect();
+ unmovingTab._moveTogetherSelectedTabsData.translateX =
+ oldTabRect.x - unmovingTabRect.x;
+ unmovingTab._moveTogetherSelectedTabsData.translateY =
+ oldTabRect.y - unmovingTabRect.y;
+ } else if (this._tabbrowserTabs.verticalMode) {
+ unmovingTab._moveTogetherSelectedTabsData.translateY =
+ newTranslateY;
+ } else {
+ unmovingTab._moveTogetherSelectedTabsData.translateX =
+ newTranslateX;
+ }
+ } else {
+ unmovingTab.currentIndex = unmovingTab.elementIndex;
+ }
+ }
+
+ // Animate left or top selected tabs
+ for (let i = 0; i < tabIndex; i++) {
+ let movingTab = selectedTabs[i];
+ if (animate) {
+ addAnimationData(movingTab);
+ } else {
+ gBrowser.moveTabBefore(movingTab, tab);
+ }
+ }
+
+ // Animate right or bottom selected tabs
+ for (let i = selectedTabs.length - 1; i > tabIndex; i--) {
+ let movingTab = selectedTabs[i];
+ if (animate) {
+ addAnimationData(movingTab);
+ } else {
+ gBrowser.moveTabAfter(movingTab, tab);
+ }
+ }
+
+ // Slide the relevant tabs to their new position.
+ // non-moving tabs adjust for RTL
+ for (let item of this._tabbrowserTabs.ariaFocusableItems) {
+ if (
+ !tab._dragData.movingTabsSet.has(item) &&
+ (item._moveTogetherSelectedTabsData?.translateX ||
+ item._moveTogetherSelectedTabsData?.translateY) &&
+ ((item.pinned && tab.pinned) || (!item.pinned && !tab.pinned))
+ ) {
+ let element = elementToMove(item);
+ if (isGrid) {
+ element.style.transform = `translate(${(this._rtlMode ? -1 : 1) * item._moveTogetherSelectedTabsData.translateX}px, ${item._moveTogetherSelectedTabsData.translateY}px)`;
+ } else if (this._tabbrowserTabs.verticalMode) {
+ element.style.transform = `translateY(${item._moveTogetherSelectedTabsData.translateY}px)`;
+ } else {
+ element.style.transform = `translateX(${(this._rtlMode ? -1 : 1) * item._moveTogetherSelectedTabsData.translateX}px)`;
+ }
+ }
+ }
+ // moving tabs don't adjust for RTL
+ for (let item of selectedTabs) {
+ if (
+ item._moveTogetherSelectedTabsData?.translateX ||
+ item._moveTogetherSelectedTabsData?.translateY
+ ) {
+ let element = elementToMove(item);
+ element.style.transform = `translate(${item._moveTogetherSelectedTabsData.translateX}px, ${item._moveTogetherSelectedTabsData.translateY}px)`;
+ }
+ }
+ }
+
+ finishMoveTogetherSelectedTabs(tab) {
+ if (
+ !tab._moveTogetherSelectedTabsData ||
+ tab._moveTogetherSelectedTabsData.finished
+ ) {
+ return;
+ }
+
+ tab._moveTogetherSelectedTabsData.finished = true;
+
+ let selectedTabs = gBrowser.selectedTabs;
+ let tabIndex = selectedTabs.indexOf(tab);
+
+ // Moving left or top tabs
+ for (let i = 0; i < tabIndex; i++) {
+ gBrowser.moveTabBefore(selectedTabs[i], tab);
+ }
+
+ // Moving right or bottom tabs
+ for (let i = selectedTabs.length - 1; i > tabIndex; i--) {
+ gBrowser.moveTabAfter(selectedTabs[i], tab);
+ }
+
+ for (let item of this._tabbrowserTabs.ariaFocusableItems) {
+ delete item._moveTogetherSelectedTabsData;
+ item = elementToMove(item);
+ item.style.transform = "";
+ item.removeAttribute("multiselected-move-together");
+ }
+ }
+
+ /* In order to to drag tabs between both the pinned arrowscrollbox (pinned tab container)
+ and unpinned arrowscrollbox (tabbrowser-arrowscrollbox), the dragged tabs need to be
+ positioned absolutely. This results in a shift in the layout, filling the empty space.
+ This function updates the position and widths of elements affected by this layout shift
+ when the tab is first selected to be dragged.
+ */
+ _updateTabStylesOnDrag(tab, dropEffect) {
+ let tabStripItemElement = elementToMove(tab);
+ tabStripItemElement.style.pointerEvents =
+ dropEffect == "copy" ? "auto" : "";
+ if (tabStripItemElement.hasAttribute("dragtarget")) {
+ return;
+ }
+ let isPinned = tab.pinned;
+ let allTabs = this._tabbrowserTabs.ariaFocusableItems;
+ let isGrid = this._isContainerVerticalPinnedGrid(tab);
+ let periphery = document.getElementById(
+ "tabbrowser-arrowscrollbox-periphery"
+ );
+
+ if (isPinned && this._tabbrowserTabs.verticalMode) {
+ this._tabbrowserTabs.pinnedTabsContainer.setAttribute("dragActive", "");
+ }
+
+ // Ensure tab containers retain size while tabs are dragged out of the layout
+ let pinnedRect = window.windowUtils.getBoundsWithoutFlushing(
+ this._tabbrowserTabs.pinnedTabsContainer.scrollbox
+ );
+ let pinnedContainerRect = window.windowUtils.getBoundsWithoutFlushing(
+ this._tabbrowserTabs.pinnedTabsContainer
+ );
+ let unpinnedRect = window.windowUtils.getBoundsWithoutFlushing(
+ this._tabbrowserTabs.arrowScrollbox.scrollbox
+ );
+ let tabContainerRect = window.windowUtils.getBoundsWithoutFlushing(
+ this._tabbrowserTabs
+ );
+
+ if (this._tabbrowserTabs.pinnedTabsContainer.firstChild) {
+ this._tabbrowserTabs.pinnedTabsContainer.scrollbox.style.height =
+ pinnedRect.height + "px";
+ // Use "minHeight" so as not to interfere with user preferences for height.
+ this._tabbrowserTabs.pinnedTabsContainer.style.minHeight =
+ pinnedContainerRect.height + "px";
+ this._tabbrowserTabs.pinnedTabsContainer.scrollbox.style.width =
+ pinnedRect.width + "px";
+ }
+ this._tabbrowserTabs.arrowScrollbox.scrollbox.style.height =
+ unpinnedRect.height + "px";
+ this._tabbrowserTabs.arrowScrollbox.scrollbox.style.width =
+ unpinnedRect.width + "px";
+
+ let { movingTabs, movingTabsSet, expandGroupOnDrop } = tab._dragData;
+ /** @type {(MozTabbrowserTab|typeof MozTabbrowserTabGroup.labelElement)[]} */
+ let suppressTransitionsFor = [];
+ /** @type {Map<MozTabbrowserTab, DOMRect>} */
+
+ const tabsOrigBounds = new Map();
+
+ for (let t of allTabs) {
+ t = elementToMove(t);
+ let tabRect = window.windowUtils.getBoundsWithoutFlushing(t);
+
+ // record where all the tabs were before we position:absolute the moving tabs
+ tabsOrigBounds.set(t, tabRect);
+
+ // Prevent flex rules from resizing non dragged tabs while the dragged
+ // tabs are positioned absolutely
+ t.style.maxWidth = tabRect.width + "px";
+ // Prevent non-moving tab strip items from performing any animations
+ // at the very beginning of the drag operation; this prevents them
+ // from appearing to move while the dragged tabs are positioned absolutely
+ let isTabInCollapsingGroup = expandGroupOnDrop && t.group == tab.group;
+ if (!movingTabsSet.has(t) && !isTabInCollapsingGroup) {
+ t.style.transition = "none";
+ suppressTransitionsFor.push(t);
+ }
+ }
+
+ if (suppressTransitionsFor.length) {
+ window
+ .promiseDocumentFlushed(() => {})
+ .then(() => {
+ window.requestAnimationFrame(() => {
+ for (let t of suppressTransitionsFor) {
+ t.style.transition = "";
+ }
+ });
+ });
+ }
+
+ // Use .tab-group-label-container or .tabbrowser-tab for size/position
+ // calculations.
+ let rect =
+ window.windowUtils.getBoundsWithoutFlushing(tabStripItemElement);
+ // Vertical tabs live under the #sidebar-main element which gets animated and has a
+ // transform style property, making it the containing block for all its descendants.
+ // Position:absolute elements need to account for this when updating position using
+ // other measurements whose origin is the viewport or documentElement's 0,0
+ let movingTabsOffsetX = window.windowUtils.getBoundsWithoutFlushing(
+ tabStripItemElement.offsetParent
+ ).x;
+
+ for (let movingTab of movingTabs) {
+ movingTab = elementToMove(movingTab);
+ movingTab.style.width = rect.width + "px";
+ // "dragtarget" contains the following rules which must only be set AFTER the above
+ // elements have been adjusted. {z-index: 3 !important, position: absolute !important}
+ movingTab.setAttribute("dragtarget", "");
+ if (isTabGroupLabel(tab)) {
+ if (this._tabbrowserTabs.verticalMode) {
+ movingTab.style.top = rect.top - unpinnedRect.top + "px";
+ } else {
+ movingTab.style.left = rect.left - movingTabsOffsetX + "px";
+ movingTab.style.height = rect.height + "px";
+ }
+ } else if (isGrid) {
+ movingTab.style.top = rect.top - pinnedRect.top + "px";
+ movingTab.style.left = rect.left + "px";
+ } else if (this._tabbrowserTabs.verticalMode) {
+ movingTab.style.top = rect.top - tabContainerRect.top + "px";
+ } else if (this._rtlMode) {
+ movingTab.style.left = rect.left + "px";
+ } else {
+ movingTab.style.left = rect.left + "px";
+ }
+ }
+
+ if (movingTabs.length == 2) {
+ tab.setAttribute("small-stack", "");
+ } else if (movingTabs.length > 2) {
+ tab.setAttribute("big-stack", "");
+ }
+
+ if (
+ !isPinned &&
+ this._tabbrowserTabs.arrowScrollbox.hasAttribute("overflowing")
+ ) {
+ if (this._tabbrowserTabs.verticalMode) {
+ periphery.style.marginBlockStart = rect.height + "px";
+ } else {
+ periphery.style.marginInlineStart = rect.width + "px";
+ }
+ } else if (
+ isPinned &&
+ this._tabbrowserTabs.pinnedTabsContainer.hasAttribute("overflowing")
+ ) {
+ let pinnedPeriphery = document.createXULElement("hbox");
+ pinnedPeriphery.id = "pinned-tabs-container-periphery";
+ pinnedPeriphery.style.width = "100%";
+ pinnedPeriphery.style.marginBlockStart = rect.height + "px";
+ this._tabbrowserTabs.pinnedTabsContainer.appendChild(pinnedPeriphery);
+ }
+
+ let setElPosition = el => {
+ let origBounds = tabsOrigBounds.get(el);
+ if (!origBounds) {
+ // No bounds saved for this tab
+ return;
+ }
+ // We use getBoundingClientRect and force a reflow as we need to know their new positions
+ // after making the moving tabs position:absolute
+ let newBounds = el.getBoundingClientRect();
+ let shiftX = origBounds.x - newBounds.x;
+ let shiftY = origBounds.y - newBounds.y;
+
+ if (!this._tabbrowserTabs.verticalMode || isGrid) {
+ el.style.left = shiftX + "px";
+ }
+ if (this._tabbrowserTabs.verticalMode) {
+ el.style.top = shiftY + "px";
+ }
+ };
+
+ // Update tabs in the same container as the dragged tabs so as not
+ // to fill the space when the dragged tabs become absolute
+ for (let t of allTabs) {
+ t = elementToMove(t);
+ if (!t.hasAttribute("dragtarget")) {
+ setElPosition(t);
+ }
+ }
+
+ // Handle the new tab button filling the space when the dragged tab
+ // position becomes absolute
+ if (!this._tabbrowserTabs.overflowing && !isPinned) {
+ if (this._tabbrowserTabs.verticalMode) {
+ periphery.style.top = `${rect.height}px`;
+ } else if (this._rtlMode) {
+ periphery.style.left = `${-rect.width}px`;
+ } else {
+ periphery.style.left = `${rect.width}px`;
+ }
+ }
+ }
+
+ // eslint-disable-next-line complexity
+ _animateTabMove(event) {
+ let draggedTab = event.dataTransfer.mozGetDataAt(TAB_DROP_TYPE, 0);
+ let dragData = draggedTab._dragData;
+ let movingTabs = dragData.movingTabs;
+ let movingTabsSet = dragData.movingTabsSet;
+
+ dragData.animLastScreenPos ??= this._tabbrowserTabs.verticalMode
+ ? dragData.screenY
+ : dragData.screenX;
+ let screen = this._tabbrowserTabs.verticalMode
+ ? event.screenY
+ : event.screenX;
+ if (screen == dragData.animLastScreenPos) {
+ return;
+ }
+ let screenForward = screen > dragData.animLastScreenPos;
+ dragData.animLastScreenPos = screen;
+
+ this._clearDragOverGroupingTimer();
+
+ let isPinned = draggedTab.pinned;
+ let numPinned = gBrowser.pinnedTabCount;
+ let allTabs = this._tabbrowserTabs.ariaFocusableItems;
+ let tabs = allTabs.slice(
+ isPinned ? 0 : numPinned,
+ isPinned ? numPinned : undefined
+ );
+
+ if (this._rtlMode) {
+ tabs.reverse();
+ }
+
+ let bounds = ele => window.windowUtils.getBoundsWithoutFlushing(ele);
+ let logicalForward = screenForward != this._rtlMode;
+ let screenAxis = this._tabbrowserTabs.verticalMode
+ ? "screenY"
+ : "screenX";
+ let size = this._tabbrowserTabs.verticalMode ? "height" : "width";
+ let translateAxis = this._tabbrowserTabs.verticalMode
+ ? "translateY"
+ : "translateX";
+ let translateX = event.screenX - dragData.screenX;
+ let translateY = event.screenY - dragData.screenY;
+
+ // Move the dragged tab based on the mouse position.
+ let periphery = document.getElementById(
+ "tabbrowser-arrowscrollbox-periphery"
+ );
+ let endEdge = ele => ele[screenAxis] + bounds(ele)[size];
+ let endScreen = endEdge(draggedTab);
+ let startScreen = draggedTab[screenAxis];
+ let { width: tabWidth, height: tabHeight } = bounds(
+ elementToMove(draggedTab)
+ );
+ let tabSize = this._tabbrowserTabs.verticalMode ? tabHeight : tabWidth;
+ let shiftSize = tabSize;
+ dragData.tabWidth = tabWidth;
+ dragData.tabHeight = tabHeight;
+ dragData.translateX = translateX;
+ dragData.translateY = translateY;
+ let translate = screen - dragData[screenAxis];
+
+ // Constrain the range over which the moving tabs can move between the edge of the tabstrip and periphery.
+ // Add 1 to periphery so we don't overlap it.
+ let startBound = this._rtlMode
+ ? endEdge(periphery) + 1 - startScreen
+ : this._tabbrowserTabs[screenAxis] - startScreen;
+ let endBound = this._rtlMode
+ ? endEdge(this._tabbrowserTabs) - endScreen
+ : periphery[screenAxis] - 1 - endScreen;
+ translate = Math.min(Math.max(translate, startBound), endBound);
+
+ // Center the tab under the cursor if the tab is not under the cursor while dragging
+ let draggedTabScreenAxis = draggedTab[screenAxis] + translate;
+ if (
+ (screen < draggedTabScreenAxis ||
+ screen > draggedTabScreenAxis + tabSize) &&
+ draggedTabScreenAxis + tabSize < endBound &&
+ draggedTabScreenAxis > startBound
+ ) {
+ translate = screen - draggedTab[screenAxis] - tabSize / 2;
+ // Ensure, after the above calculation, we are still within bounds
+ translate = Math.min(Math.max(translate, startBound), endBound);
+ }
+
+ if (!gBrowser.pinnedTabCount && !this._dragToPinPromoCard.shouldRender) {
+ let pinnedDropIndicatorMargin = parseFloat(
+ window.getComputedStyle(this._pinnedDropIndicator).marginInline
+ );
+ this._checkWithinPinnedContainerBounds({
+ firstMovingTabScreen: startScreen,
+ lastMovingTabScreen: endScreen,
+ pinnedTabsStartEdge: this._rtlMode
+ ? endEdge(this._tabbrowserTabs.arrowScrollbox) +
+ pinnedDropIndicatorMargin
+ : this._tabbrowserTabs[screenAxis],
+ pinnedTabsEndEdge: this._rtlMode
+ ? endEdge(this._tabbrowserTabs)
+ : this._tabbrowserTabs.arrowScrollbox[screenAxis] -
+ pinnedDropIndicatorMargin,
+ translate,
+ draggedTab,
+ });
+ }
+
+ for (let item of movingTabs) {
+ item = elementToMove(item);
+ item.style.transform = `${translateAxis}(${translate}px)`;
+ }
+
+ dragData.translatePos = translate;
+
+ tabs = tabs.filter(t => !movingTabsSet.has(t) || t == draggedTab);
+
+ /**
+ * When the `draggedTab` is just starting to move, the `draggedTab` is in
+ * its original location and the `dropElementIndex == draggedTab.elementIndex`.
+ * Any tabs or tab group labels passed in as `item` will result in a 0 shift
+ * because all of those items should also continue to appear in their original
+ * locations.
+ *
+ * Once the `draggedTab` is more "backward" in the tab strip than its original
+ * position, any tabs or tab group labels between the `draggedTab`'s original
+ * `elementIndex` and the current `dropElementIndex` should shift "forward"
+ * out of the way of the dragging tabs.
+ *
+ * When the `draggedTab` is more "forward" in the tab strip than its original
+ * position, any tabs or tab group labels between the `draggedTab`'s original
+ * `elementIndex` and the current `dropElementIndex` should shift "backward"
+ * out of the way of the dragging tabs.
+ *
+ * @param {MozTabbrowserTab|MozTabbrowserTabGroup.label} item
+ * @param {number} dropElementIndex
+ * @returns {number}
+ */
+ let getTabShift = (item, dropElementIndex) => {
+ if (!item?.currentIndex) {
+ item.currentIndex = item.elementIndex;
+ }
+ if (
+ item.currentIndex < draggedTab.elementIndex &&
+ item.currentIndex >= dropElementIndex
+ ) {
+ return this._rtlMode ? -shiftSize : shiftSize;
+ }
+ if (
+ item.currentIndex > draggedTab.elementIndex &&
+ item.currentIndex < dropElementIndex
+ ) {
+ return this._rtlMode ? shiftSize : -shiftSize;
+ }
+ return 0;
+ };
+
+ let oldDropElementIndex =
+ dragData.animDropElementIndex ?? draggedTab.elementIndex;
+
+ /**
+ * Returns the higher % by which one element overlaps another
+ * in the tab strip.
+ *
+ * When element 1 is further forward in the tab strip:
+ *
+ * p1 p2 p1+s1 p2+s2
+ * | | | |
+ * ---------------------------------
+ * ========================
+ * s1
+ * ===================
+ * s2
+ * ==========
+ * overlap
+ *
+ * When element 2 is further forward in the tab strip:
+ *
+ * p2 p1 p2+s2 p1+s1
+ * | | | |
+ * ---------------------------------
+ * ========================
+ * s2
+ * ===================
+ * s1
+ * ==========
+ * overlap
+ *
+ * @param {number} p1
+ * Position (x or y value in screen coordinates) of element 1.
+ * @param {number} s1
+ * Size (width or height) of element 1.
+ * @param {number} p2
+ * Position (x or y value in screen coordinates) of element 2.
+ * @param {number} s2
+ * Size (width or height) of element 1.
+ * @returns {number}
+ * Percent between 0.0 and 1.0 (inclusive) of element 1 or element 2
+ * that is overlapped by the other element. If the elements have
+ * different sizes, then this returns the larger overlap percentage.
+ */
+ function greatestOverlap(p1, s1, p2, s2) {
+ let overlapSize;
+ if (p1 < p2) {
+ // element 1 starts first
+ overlapSize = p1 + s1 - p2;
+ } else {
+ // element 2 starts first
+ overlapSize = p2 + s2 - p1;
+ }
+
+ // No overlap if size is <= 0
+ if (overlapSize <= 0) {
+ return 0;
+ }
+
+ // Calculate the overlap fraction from each element's perspective.
+ let overlapPercent = Math.max(overlapSize / s1, overlapSize / s2);
+
+ return Math.min(overlapPercent, 1);
+ }
+
+ /**
+ * Determine what tab/tab group label we're dragging over.
+ *
+ * When dragging right or downwards, the reference point for overlap is
+ * the right or bottom edge of the most forward moving tab.
+ *
+ * When dragging left or upwards, the reference point for overlap is the
+ * left or top edge of the most backward moving tab.
+ *
+ * @returns {Element|null}
+ * The tab or tab group label that should be used to visually shift tab
+ * strip elements out of the way of the dragged tab(s) during a drag
+ * operation. Note: this is not used to determine where the dragged
+ * tab(s) will be dropped, it is only used for visual animation at this
+ * time.
+ */
+ let getOverlappedElement = () => {
+ let point = (screenForward ? endScreen : startScreen) + translate;
+ let low = 0;
+ let high = tabs.length - 1;
+ while (low <= high) {
+ let mid = Math.floor((low + high) / 2);
+ if (tabs[mid] == draggedTab && ++mid > high) {
+ break;
+ }
+ let element = tabs[mid];
+ let elementForSize = elementToMove(element);
+ screen =
+ elementForSize[screenAxis] +
+ getTabShift(element, oldDropElementIndex);
+
+ if (screen > point) {
+ high = mid - 1;
+ } else if (screen + bounds(elementForSize)[size] < point) {
+ low = mid + 1;
+ } else {
+ return element;
+ }
+ }
+ return null;
+ };
+
+ let dropElement = getOverlappedElement();
+
+ let newDropElementIndex;
+ if (dropElement) {
+ newDropElementIndex =
+ dropElement?.currentIndex ?? dropElement.elementIndex;
+ } else {
+ // When the dragged element(s) moves past a tab strip item, the dragged
+ // element's leading edge starts dragging over empty space, resulting in
+ // no overlapping `dropElement`. In these cases, try to fall back to the
+ // previous animation drop element index to avoid unstable animations
+ // (tab strip items snapping back and forth to shift out of the way of
+ // the dragged element(s)).
+ newDropElementIndex = oldDropElementIndex;
+
+ // We always want to have a `dropElement` so that we can determine where to
+ // logically drop the dragged element(s).
+ //
+ // It's tempting to set `dropElement` to
+ // `this.ariaFocusableItems.at(oldDropElementIndex)`, and that is correct
+ // for most cases, but there are edge cases:
+ //
+ // 1) the drop element index range needs to be one larger than the number of
+ // items that can move in the tab strip. The simplest example is when all
+ // tabs are ungrouped and unpinned: for 5 tabs, the drop element index needs
+ // to be able to go from 0 (become the first tab) to 5 (become the last tab).
+ // `this.ariaFocusableItems.at(5)` would be `undefined` when dragging to the
+ // end of the tab strip. In this specific case, it works to fall back to
+ // setting the drop element to the last tab.
+ //
+ // 2) the `elementIndex` values of the tab strip items do not change during
+ // the drag operation. When dragging the last tab or multiple tabs at the end
+ // of the tab strip, having `dropElement` fall back to the last tab makes the
+ // drop element one of the moving tabs. This can have some unexpected behavior
+ // if not careful. Falling back to the last tab that's not moving (instead of
+ // just the last tab) helps ensure that `dropElement` is always a stable target
+ // to drop next to.
+ //
+ // 3) all of the elements in the tab strip are moving, in which case there can't
+ // be a drop element and it should stay `undefined`.
+ //
+ // 4) we just started dragging and the `oldDropElementIndex` has its default
+ // valuë of `movingTabs[0].elementIndex`. In this case, the drop element
+ // shouldn't be a moving tab, so keep it `undefined`.
+ let lastPossibleDropElement = this._rtlMode
+ ? tabs.find(t => t != draggedTab)
+ : tabs.findLast(t => t != draggedTab);
+ let maxElementIndexForDropElement =
+ lastPossibleDropElement?.currentIndex ??
+ lastPossibleDropElement?.elementIndex;
+ if (Number.isInteger(maxElementIndexForDropElement)) {
+ let index = Math.min(
+ oldDropElementIndex,
+ maxElementIndexForDropElement
+ );
+ let oldDropElementCandidate = this._tabbrowserTabs.ariaFocusableItems
+ .filter(t => !movingTabsSet.has(t) || t == draggedTab)
+ .at(index);
+ if (!movingTabsSet.has(oldDropElementCandidate)) {
+ dropElement = oldDropElementCandidate;
+ }
+ }
+ }
+
+ let moveOverThreshold;
+ let overlapPercent;
+ let dropBefore;
+ if (dropElement) {
+ let dropElementForOverlap = elementToMove(dropElement);
+
+ let dropElementScreen = dropElementForOverlap[screenAxis];
+ let dropElementPos =
+ dropElementScreen + getTabShift(dropElement, oldDropElementIndex);
+ let dropElementSize = bounds(dropElementForOverlap)[size];
+ let firstMovingTabPos = startScreen + translate;
+ overlapPercent = greatestOverlap(
+ firstMovingTabPos,
+ shiftSize,
+ dropElementPos,
+ dropElementSize
+ );
+
+ moveOverThreshold = gBrowser._tabGroupsEnabled
+ ? Services.prefs.getIntPref(
+ "browser.tabs.dragDrop.moveOverThresholdPercent"
+ ) / 100
+ : 0.5;
+ moveOverThreshold = Math.min(1, Math.max(0, moveOverThreshold));
+ let shouldMoveOver = overlapPercent > moveOverThreshold;
+ if (logicalForward && shouldMoveOver) {
+ newDropElementIndex++;
+ } else if (!logicalForward && !shouldMoveOver) {
+ newDropElementIndex++;
+ if (newDropElementIndex > oldDropElementIndex) {
+ // FIXME: Not quite sure what's going on here, but this check
+ // prevents jittery back-and-forth movement of background tabs
+ // in certain cases.
+ newDropElementIndex = oldDropElementIndex;
+ }
+ }
+
+ // Recalculate the overlap with the updated drop index for when the
+ // drop element moves over.
+ dropElementPos =
+ dropElementScreen + getTabShift(dropElement, newDropElementIndex);
+ overlapPercent = greatestOverlap(
+ firstMovingTabPos,
+ shiftSize,
+ dropElementPos,
+ dropElementSize
+ );
+ dropBefore = firstMovingTabPos < dropElementPos;
+ if (this._rtlMode) {
+ dropBefore = !dropBefore;
+ }
+
+ // If dragging a group over another group, don't make it look like it is
+ // possible to drop the dragged group inside the other group.
+ if (
+ isTabGroupLabel(draggedTab) &&
+ dropElement?.group &&
+ (!dropElement.group.collapsed ||
+ (dropElement.group.collapsed && dropElement.group.hasActiveTab))
+ ) {
+ let overlappedGroup = dropElement.group;
+
+ if (isTabGroupLabel(dropElement)) {
+ dropBefore = true;
+ newDropElementIndex =
+ dropElement?.currentIndex ?? dropElement.elementIndex;
+ } else {
+ dropBefore = false;
+ let lastVisibleTabInGroup = overlappedGroup.tabs.findLast(
+ tab => tab.visible
+ );
+ newDropElementIndex =
+ (lastVisibleTabInGroup?.currentIndex ??
+ lastVisibleTabInGroup.elementIndex) + 1;
+ }
+
+ dropElement = overlappedGroup;
+ }
+
+ // Constrain drop direction at the boundary between pinned and
+ // unpinned tabs so that they don't mix together.
+ let isOutOfBounds = isPinned
+ ? dropElement.elementIndex >= numPinned
+ : dropElement.elementIndex < numPinned;
+ if (isOutOfBounds) {
+ // Drop after last pinned tab
+ dropElement = this._tabbrowserTabs.ariaFocusableItems[numPinned - 1];
+ dropBefore = false;
+ }
+ }
+
+ if (
+ gBrowser._tabGroupsEnabled &&
+ isTab(draggedTab) &&
+ !isPinned &&
+ (!numPinned || newDropElementIndex > numPinned)
+ ) {
+ let dragOverGroupingThreshold = 1 - moveOverThreshold;
+ let groupingDelay = Services.prefs.getIntPref(
+ "browser.tabs.dragDrop.createGroup.delayMS"
+ );
+
+ // When dragging tab(s) over an ungrouped tab, signal to the user
+ // that dropping the tab(s) will create a new tab group.
+ let shouldCreateGroupOnDrop =
+ !movingTabsSet.has(dropElement) &&
+ isTab(dropElement) &&
+ !dropElement?.group &&
+ overlapPercent > dragOverGroupingThreshold;
+
+ // When dragging tab(s) over a collapsed tab group label, signal to the
+ // user that dropping the tab(s) will add them to the group.
+ let shouldDropIntoCollapsedTabGroup =
+ isTabGroupLabel(dropElement) &&
+ dropElement.group.collapsed &&
+ overlapPercent > dragOverGroupingThreshold;
+
+ if (shouldCreateGroupOnDrop) {
+ this._dragOverGroupingTimer = setTimeout(() => {
+ this._triggerDragOverGrouping(dropElement);
+ dragData.shouldCreateGroupOnDrop = true;
+ this._setDragOverGroupColor(dragData.tabGroupCreationColor);
+ }, groupingDelay);
+ } else if (shouldDropIntoCollapsedTabGroup) {
+ this._dragOverGroupingTimer = setTimeout(() => {
+ this._triggerDragOverGrouping(dropElement);
+ dragData.shouldDropIntoCollapsedTabGroup = true;
+ this._setDragOverGroupColor(dropElement.group.color);
+ }, groupingDelay);
+ } else {
+ this._tabbrowserTabs.removeAttribute("movingtab-group");
+ this._resetGroupTarget(
+ document.querySelector("[dragover-groupTarget]")
+ );
+
+ delete dragData.shouldCreateGroupOnDrop;
+ delete dragData.shouldDropIntoCollapsedTabGroup;
+
+ // Default to dropping into `dropElement`'s tab group, if it exists.
+ let dropElementGroup = dropElement?.group;
+ let colorCode = dropElementGroup?.color;
+
+ let lastUnmovingTabInGroup = dropElementGroup?.tabs.findLast(
+ t => !movingTabsSet.has(t)
+ );
+ if (
+ isTab(dropElement) &&
+ dropElementGroup &&
+ dropElement == lastUnmovingTabInGroup &&
+ !dropBefore &&
+ overlapPercent < dragOverGroupingThreshold
+ ) {
+ // Dragging tab over the last tab of a tab group, but not enough
+ // for it to drop into the tab group. Drop it after the tab group instead.
+ dropElement = dropElementGroup;
+ colorCode = undefined;
+ } else if (isTabGroupLabel(dropElement)) {
+ if (dropBefore) {
+ // Dropping right before the tab group.
+ dropElement = dropElementGroup;
+ colorCode = undefined;
+ } else if (dropElementGroup.collapsed) {
+ // Dropping right after the collapsed tab group.
+ dropElement = dropElementGroup;
+ colorCode = undefined;
+ } else {
+ // Dropping right before the first tab in the tab group.
+ dropElement = dropElementGroup.tabs[0];
+ dropBefore = true;
+ }
+ }
+ this._setDragOverGroupColor(colorCode);
+ this._tabbrowserTabs.toggleAttribute(
+ "movingtab-addToGroup",
+ colorCode
+ );
+ this._tabbrowserTabs.toggleAttribute("movingtab-ungroup", !colorCode);
+ }
+ }
+
+ if (
+ newDropElementIndex == oldDropElementIndex &&
+ dropBefore == dragData.dropBefore &&
+ dropElement == dragData.dropElement
+ ) {
+ return;
+ }
+
+ dragData.dropElement = dropElement;
+ dragData.dropBefore = dropBefore;
+ dragData.animDropElementIndex = newDropElementIndex;
+
+ // Shift background tabs to leave a gap where the dragged tab
+ // would currently be dropped.
+ for (let item of tabs) {
+ if (item == draggedTab) {
+ continue;
+ }
+ let shift = getTabShift(item, newDropElementIndex);
+ let transform = shift ? `${translateAxis}(${shift}px)` : "";
+ item = elementToMove(item);
+ item.style.transform = transform;
+ }
+ }
+
+ _animateExpandedPinnedTabMove(event) {
+ let draggedTab = event.dataTransfer.mozGetDataAt(TAB_DROP_TYPE, 0);
+ let dragData = draggedTab._dragData;
+ let movingTabs = dragData.movingTabs;
+
+ dragData.animLastScreenX ??= dragData.screenX;
+ dragData.animLastScreenY ??= dragData.screenY;
+
+ let screenX = event.screenX;
+ let screenY = event.screenY;
+
+ if (
+ screenY == dragData.animLastScreenY &&
+ screenX == dragData.animLastScreenX
+ ) {
+ return;
+ }
+
+ let tabs = this._tabbrowserTabs.visibleTabs.slice(
+ 0,
+ gBrowser.pinnedTabCount
+ );
+
+ dragData.animLastScreenY = screenY;
+ dragData.animLastScreenX = screenX;
+
+ let { width: tabWidth, height: tabHeight } =
+ draggedTab.getBoundingClientRect();
+ let shiftSizeX = tabWidth;
+ let shiftSizeY = tabHeight;
+ dragData.tabWidth = tabWidth;
+ dragData.tabHeight = tabHeight;
+
+ // Move the dragged tab based on the mouse position.
+ let periphery = document.getElementById(
+ "tabbrowser-arrowscrollbox-periphery"
+ );
+ let endScreenX = draggedTab.screenX + tabWidth;
+ let endScreenY = draggedTab.screenY + tabHeight;
+ let startScreenX = draggedTab.screenX;
+ let startScreenY = draggedTab.screenY;
+ let translateX = screenX - dragData.screenX;
+ let translateY = screenY - dragData.screenY;
+ let startBoundX = this._tabbrowserTabs.screenX - startScreenX;
+ let startBoundY = this._tabbrowserTabs.screenY - startScreenY;
+ let endBoundX =
+ this._tabbrowserTabs.screenX +
+ window.windowUtils.getBoundsWithoutFlushing(this._tabbrowserTabs)
+ .width -
+ endScreenX;
+ let endBoundY = periphery.screenY - endScreenY;
+ translateX = Math.min(Math.max(translateX, startBoundX), endBoundX);
+ translateY = Math.min(Math.max(translateY, startBoundY), endBoundY);
+
+ // Center the tab under the cursor if the tab is not under the cursor while dragging
+ if (
+ screen < draggedTab.screenY + translateY ||
+ screen > draggedTab.screenY + tabHeight + translateY
+ ) {
+ translateY = screen - draggedTab.screenY - tabHeight / 2;
+ }
+
+ for (let tab of movingTabs) {
+ tab.style.transform = `translate(${translateX}px, ${translateY}px)`;
+ }
+
+ dragData.translateX = translateX;
+ dragData.translateY = translateY;
+
+ // Determine what tab we're dragging over.
+ // * Single tab dragging: Point of reference is the center of the dragged tab. If that
+ // point touches a background tab, the dragged tab would take that
+ // tab's position when dropped.
+ // * Multiple tabs dragging: Tabs are stacked, so we can still use the above
+ // point of reference, the center of the dragged tab.
+ // * We're doing a binary search in order to reduce the amount of
+ // tabs we need to check.
+
+ tabs = tabs.filter(t => !movingTabs.includes(t) || t == draggedTab);
+ let tabCenterX = startScreenX + translateX + tabWidth / 2;
+ let tabCenterY = startScreenY + translateY + tabHeight / 2;
+
+ let shiftNumber = this._maxTabsPerRow - 1;
+
+ let getTabShift = (tab, dropIndex) => {
+ if (!tab?.currentIndex) {
+ tab.currentIndex = tab.elementIndex;
+ }
+ if (
+ tab.currentIndex < draggedTab.elementIndex &&
+ tab.currentIndex >= dropIndex
+ ) {
+ // If tab is at the end of a row, shift back and down
+ let tabRow = Math.ceil((tab.currentIndex + 1) / this._maxTabsPerRow);
+ let shiftedTabRow = Math.ceil(
+ (tab.currentIndex + 2) / this._maxTabsPerRow
+ );
+ if (tab.currentIndex && tabRow != shiftedTabRow) {
+ return [
+ RTL_UI ? tabWidth * shiftNumber : -tabWidth * shiftNumber,
+ shiftSizeY,
+ ];
+ }
+ return [RTL_UI ? -shiftSizeX : shiftSizeX, 0];
+ }
+ if (
+ tab.currentIndex > draggedTab.elementIndex &&
+ tab.currentIndex < dropIndex
+ ) {
+ // If tab is not index 0 and at the start of a row, shift across and up
+ let tabRow = Math.floor(tab.currentIndex / this._maxTabsPerRow);
+ let shiftedTabRow = Math.floor(
+ (tab.currentIndex - 1) / this._maxTabsPerRow
+ );
+ if (tab.currentIndex && tabRow != shiftedTabRow) {
+ return [
+ RTL_UI ? -tabWidth * shiftNumber : tabWidth * shiftNumber,
+ -shiftSizeY,
+ ];
+ }
+ return [RTL_UI ? shiftSizeX : -shiftSizeX, 0];
+ }
+ return [0, 0];
+ };
+
+ let low = 0;
+ let high = tabs.length - 1;
+ let newIndex = -1;
+ let oldIndex = dragData.animDropElementIndex ?? draggedTab.elementIndex;
+
+ while (low <= high) {
+ let mid = Math.floor((low + high) / 2);
+ if (tabs[mid] == draggedTab && ++mid > high) {
+ break;
+ }
+ let [shiftX, shiftY] = getTabShift(tabs[mid], oldIndex);
+ screenX = tabs[mid].screenX + shiftX;
+ screenY = tabs[mid].screenY + shiftY;
+
+ if (screenY + tabHeight < tabCenterY) {
+ low = mid + 1;
+ } else if (screenY > tabCenterY) {
+ high = mid - 1;
+ } else if (
+ RTL_UI ? screenX + tabWidth < tabCenterX : screenX > tabCenterX
+ ) {
+ high = mid - 1;
+ } else if (
+ RTL_UI ? screenX > tabCenterX : screenX + tabWidth < tabCenterX
+ ) {
+ low = mid + 1;
+ } else {
+ newIndex = tabs[mid].currentIndex;
+ break;
+ }
+ }
+
+ if (newIndex >= oldIndex && newIndex < tabs.length) {
+ newIndex++;
+ }
+
+ if (newIndex < 0) {
+ newIndex = oldIndex;
+ }
+
+ if (newIndex == dragData.animDropElementIndex) {
+ return;
+ }
+
+ dragData.animDropElementIndex = newIndex;
+ dragData.dropElement = tabs[Math.min(newIndex, tabs.length - 1)];
+ dragData.dropBefore = newIndex < tabs.length;
+
+ // Shift background tabs to leave a gap where the dragged tab
+ // would currently be dropped.
+ for (let tab of tabs) {
+ if (tab != draggedTab) {
+ let [shiftX, shiftY] = getTabShift(tab, newIndex);
+ tab.style.transform =
+ shiftX || shiftY ? `translate(${shiftX}px, ${shiftY}px)` : "";
+ }
+ }
+ }
+
+ // If the tab is dropped in another window, we need to pass in the original window document
+ _resetTabsAfterDrop(draggedTabDocument = document) {
+ if (this._tabbrowserTabs.expandOnHover) {
+ // Re-enable MousePosTracker after dropping
+ MousePosTracker.addListener(document.defaultView.SidebarController);
+ }
+
+ let pinnedDropIndicator = draggedTabDocument.getElementById(
+ "pinned-drop-indicator"
+ );
+ pinnedDropIndicator.removeAttribute("visible");
+ pinnedDropIndicator.removeAttribute("interactive");
+ draggedTabDocument.ownerGlobal.gBrowser.tabContainer.style.maxWidth = "";
+ let allTabs = draggedTabDocument.getElementsByClassName("tabbrowser-tab");
+ for (let tab of allTabs) {
+ tab.style.width = "";
+ tab.style.left = "";
+ tab.style.top = "";
+ tab.style.maxWidth = "";
+ tab.style.pointerEvents = "";
+ tab.removeAttribute("dragtarget");
+ tab.removeAttribute("small-stack");
+ tab.removeAttribute("big-stack");
+ delete tab.currentIndex;
+ }
+ for (let label of draggedTabDocument.getElementsByClassName(
+ "tab-group-label-container"
+ )) {
+ label.style.width = "";
+ label.style.maxWidth = "";
+ label.style.height = "";
+ label.style.left = "";
+ label.style.top = "";
+ label.style.pointerEvents = "";
+ label.removeAttribute("dragtarget");
+ }
+ for (let label of draggedTabDocument.getElementsByClassName(
+ "tab-group-label"
+ )) {
+ delete label.currentIndex;
+ }
+ let periphery = draggedTabDocument.getElementById(
+ "tabbrowser-arrowscrollbox-periphery"
+ );
+ periphery.style.marginBlockStart = "";
+ periphery.style.marginInlineStart = "";
+ periphery.style.left = "";
+ periphery.style.top = "";
+ let pinnedTabsContainer = draggedTabDocument.getElementById(
+ "pinned-tabs-container"
+ );
+ let pinnedPeriphery = draggedTabDocument.getElementById(
+ "pinned-tabs-container-periphery"
+ );
+ pinnedPeriphery && pinnedTabsContainer.removeChild(pinnedPeriphery);
+ pinnedTabsContainer.removeAttribute("dragActive");
+ pinnedTabsContainer.style.minHeight = "";
+ draggedTabDocument.defaultView.SidebarController.updatePinnedTabsHeightOnResize();
+ pinnedTabsContainer.scrollbox.style.height = "";
+ pinnedTabsContainer.scrollbox.style.width = "";
+ let arrowScrollbox = draggedTabDocument.getElementById(
+ "tabbrowser-arrowscrollbox"
+ );
+ arrowScrollbox.scrollbox.style.height = "";
+ arrowScrollbox.scrollbox.style.width = "";
+ for (let groupLabel of draggedTabDocument.getElementsByClassName(
+ "tab-group-label-container"
+ )) {
+ groupLabel.style.left = "";
+ groupLabel.style.top = "";
+ }
+ }
+ };
+}
diff --git a/browser/components/tabbrowser/content/tabs.js b/browser/components/tabbrowser/content/tabs.js
@@ -216,7 +216,24 @@
this.tooltip = "tabbrowser-tab-tooltip";
- this.tabDragAndDrop = new window.TabDragAndDrop(this);
+ Services.prefs.addObserver(
+ "browser.tabs.dragDrop.multiselectStacking",
+ this.boundObserve
+ );
+ this.observe(
+ null,
+ "nsPref:changed",
+ "browser.tabs.dragDrop.multiselectStacking"
+ );
+ }
+
+ #initializeDragAndDrop() {
+ this.tabDragAndDrop = Services.prefs.getBoolPref(
+ "browser.tabs.dragDrop.multiselectStacking",
+ true
+ )
+ ? new window.TabStacking(this)
+ : new window.TabDragAndDrop(this);
this.tabDragAndDrop.init();
}
@@ -1144,9 +1161,12 @@
}
}
- observe(aSubject, aTopic) {
+ observe(aSubject, aTopic, aData) {
switch (aTopic) {
case "nsPref:changed": {
+ if (aData == "browser.tabs.dragDrop.multiselectStacking") {
+ this.#initializeDragAndDrop();
+ }
// This is has to deal with changes in
// privacy.userContext.enabled and
// privacy.userContext.newTabContainerOnLeftClick.enabled.
@@ -1621,6 +1641,10 @@
destroy() {
if (this.boundObserve) {
Services.prefs.removeObserver("privacy.userContext", this.boundObserve);
+ Services.prefs.removeObserver(
+ "browser.tabs.dragDrop.multiselectStacking",
+ this.boundObserve
+ );
}
CustomizableUI.removeListener(this);
}
diff --git a/browser/components/tabbrowser/jar.mn b/browser/components/tabbrowser/jar.mn
@@ -7,6 +7,7 @@ browser.jar:
content/browser/tabbrowser/browser-ctrlTab.js (content/browser-ctrlTab.js)
content/browser/tabbrowser/browser-fullZoom.js (content/browser-fullZoom.js)
content/browser/tabbrowser/drag-and-drop.js (content/drag-and-drop.js)
+ content/browser/tabbrowser/tab-stacking.js (content/tab-stacking.js)
content/browser/tabbrowser/tab.js (content/tab.js)
content/browser/tabbrowser/tab-hover-preview.mjs (content/tab-hover-preview.mjs)
content/browser/tabbrowser/tabbrowser.js (content/tabbrowser.js)
diff --git a/browser/components/tabbrowser/test/browser/tabs/browser_multiselect_tabs_reorder.js b/browser/components/tabbrowser/test/browser/tabs/browser_multiselect_tabs_reorder.js
@@ -2,6 +2,9 @@
* http://creativecommons.org/publicdomain/zero/1.0/ */
add_task(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.tabs.dragDrop.multiselectStacking", false]],
+ });
// Disable tab animations
gReduceMotionOverride = true;
diff --git a/browser/components/tabbrowser/test/browser/tabs/browser_tab_tooltips.js b/browser/components/tabbrowser/test/browser/tabs/browser_tab_tooltips.js
@@ -12,12 +12,12 @@ function openTooltip(node) {
event => event.originalTarget.nodeName == "tooltip"
);
window.windowUtils.disableNonTestMouseEvents(true);
- EventUtils.synthesizeMouse(node, 2, 2, { type: "mouseover" });
- EventUtils.synthesizeMouse(node, 4, 4, { type: "mousemove" });
+ EventUtils.synthesizeMouse(node, 10, 10, { type: "mouseover" });
+ EventUtils.synthesizeMouse(node, 10, 10, { type: "mousemove" });
EventUtils.synthesizeMouse(node, MOUSE_OFFSET, MOUSE_OFFSET, {
type: "mousemove",
});
- EventUtils.synthesizeMouse(node, 2, 2, { type: "mouseout" });
+ EventUtils.synthesizeMouse(node, 10, 10, { type: "mouseout" });
window.windowUtils.disableNonTestMouseEvents(false);
return tooltipShownPromise;
}
diff --git a/browser/themes/shared/tabbrowser/tabs.css b/browser/themes/shared/tabbrowser/tabs.css
@@ -30,7 +30,7 @@
--tab-pinned-min-width-expanded: calc(var(--tab-pinned-expanded-background-width) + 2 * var(--tab-pinned-margin-inline-expanded));
--tab-pinned-container-margin-inline-expanded: var(--space-small);
--tab-border-radius: var(--toolbarbutton-border-radius);
- --tab-overflow-clip-margin: 2px;
+ --tab-overflow-clip-margin: 4px;
--tab-close-button-padding: 6px;
--tab-block-margin: 4px;
--tab-icon-end-margin: 5.5px;
@@ -218,6 +218,13 @@
z-index: 3 !important;
position: absolute !important;
pointer-events: none; /* avoid blocking dragover events on scroll buttons */
+
+ /* stylelint-disable-next-line media-query-no-invalid */
+ @media -moz-pref("browser.tabs.dragDrop.multiselectStacking") {
+ &[multiselected]:not([small-stack], [big-stack]) {
+ visibility: hidden;
+ }
+ }
}
&:not([pinned], [fadein]) {
@@ -238,8 +245,16 @@
}
}
- &[multiselected-move-together][multiselected]:not([selected]) {
- z-index: 2;
+ &[multiselected-move-together][multiselected] {
+ z-index: 3;
+
+ &[visually-selected] {
+ z-index: 4;
+ }
+
+ &:not([selected]) {
+ z-index: 2;
+ }
}
/* stylelint-disable-next-line media-query-no-invalid */
@@ -877,6 +892,102 @@
}
}
+ #tabbrowser-tabs[orient="vertical"]:not([movingtab-group]) & {
+ .tabbrowser-tab[small-stack] > .tab-stack > & {
+ box-shadow:
+ 0 5px 0 -3px var(--tab-selected-bgcolor),
+ 0 3px var(--focus-outline-color);
+ }
+
+ .tabbrowser-tab[big-stack] > .tab-stack > & {
+ box-shadow:
+ 0 5px 0 -3px var(--tab-selected-bgcolor),
+ 0 3px var(--focus-outline-color),
+ 0 8px 0 -3px var(--tab-selected-bgcolor),
+ 0 6px var(--focus-outline-color);
+ }
+ }
+
+ #tabbrowser-tabs[orient="horizontal"]:not([movingtab-group]) & {
+ .tabbrowser-tab[small-stack] > .tab-stack > & {
+ box-shadow:
+ 5px 0 0 -3px var(--tab-selected-bgcolor),
+ 3px 0 var(--focus-outline-color);
+ }
+
+ .tabbrowser-tab[big-stack] > .tab-stack > & {
+ box-shadow:
+ 5px 0 0 -3px var(--tab-selected-bgcolor),
+ 3px 0 var(--focus-outline-color),
+ 8px 0 0 -3px var(--tab-selected-bgcolor),
+ 6px 0 var(--focus-outline-color);
+ }
+
+ &:-moz-locale-dir(rtl) {
+ .tabbrowser-tab[small-stack] > .tab-stack > & {
+ box-shadow:
+ -5px 0 0 -3px var(--tab-selected-bgcolor),
+ -3px 0 var(--focus-outline-color);
+ }
+
+ .tabbrowser-tab[big-stack] > .tab-stack > & {
+ box-shadow:
+ -5px 0 0 -3px var(--tab-selected-bgcolor),
+ -3px 0 var(--focus-outline-color),
+ -8px 0 0 -3px var(--tab-selected-bgcolor),
+ -6px 0 var(--focus-outline-color);
+ }
+ }
+ }
+
+ #tabbrowser-tabs[orient="vertical"][movingtab-group] & {
+ .tabbrowser-tab[small-stack] > .tab-stack > & {
+ box-shadow:
+ 0 5px 0 -3px var(--tab-selected-bgcolor),
+ 0 3px var(--dragover-tab-group-color);
+ }
+
+ .tabbrowser-tab[big-stack] > .tab-stack > & {
+ box-shadow:
+ 0 5px 0 -3px var(--tab-selected-bgcolor),
+ 0 3px var(--dragover-tab-group-color),
+ 0 8px 0 -3px var(--tab-selected-bgcolor),
+ 0 6px var(--dragover-tab-group-color);
+ }
+ }
+
+ #tabbrowser-tabs[orient="horizontal"][movingtab-group] & {
+ .tabbrowser-tab[small-stack] > .tab-stack > & {
+ box-shadow:
+ 5px 0 0 -3px var(--tab-selected-bgcolor),
+ 3px 0 var(--dragover-tab-group-color);
+ }
+
+ .tabbrowser-tab[big-stack] > .tab-stack > & {
+ box-shadow:
+ 5px 0 0 -3px var(--tab-selected-bgcolor),
+ 3px 0 var(--dragover-tab-group-color),
+ 8px 0 0 -3px var(--tab-selected-bgcolor),
+ 6px 0 var(--dragover-tab-group-color);
+ }
+
+ &:-moz-locale-dir(rtl) {
+ .tabbrowser-tab[small-stack] > .tab-stack > & {
+ box-shadow:
+ -5px 0 0 -3px var(--tab-selected-bgcolor),
+ -3px 0 var(--dragover-tab-group-color);
+ }
+
+ .tabbrowser-tab[big-stack] > .tab-stack > & {
+ box-shadow:
+ -5px 0 0 -3px var(--tab-selected-bgcolor),
+ -3px 0 var(--dragover-tab-group-color),
+ -8px 0 0 -3px var(--tab-selected-bgcolor),
+ -6px 0 var(--dragover-tab-group-color);
+ }
+ }
+ }
+
#tabbrowser-tabs[movingtab] & {
transition:
background-color 50ms ease,