feat: Add support for drag-and-dropping tabs into groups, b=no-bug, c=common, folders, tabs, workspaces

This commit is contained in:
mr. m
2025-12-16 14:01:44 +01:00
parent f044433bd1
commit 04833ad090
7 changed files with 187 additions and 44 deletions

View File

@@ -1,5 +1,5 @@
diff --git a/browser/components/tabbrowser/content/drag-and-drop.js b/browser/components/tabbrowser/content/drag-and-drop.js
index 97b931c3c7385a52d20204369fcf6d6999053687..13e872a6c11061b5d7e669476072075d0e685eb9 100644
index 97b931c3c7385a52d20204369fcf6d6999053687..b028c923d24adf0e9dbe12f80deb3ad4fda535eb 100644
--- a/browser/components/tabbrowser/content/drag-and-drop.js
+++ b/browser/components/tabbrowser/content/drag-and-drop.js
@@ -32,6 +32,9 @@
@@ -175,16 +175,23 @@ index 97b931c3c7385a52d20204369fcf6d6999053687..13e872a6c11061b5d7e669476072075d
let tabsPerRow = 0;
let position = RTL_UI
? window.windowUtils.getBoundsWithoutFlushing(
@@ -1055,7 +1091,7 @@
@@ -1055,7 +1091,6 @@
// using updateDragImage. On Linux, we can use a panel.
if (platform == "win" || platform == "macosx") {
captureListener = function () {
- dt.updateDragImage(canvas, dragImageOffset, dragImageOffset);
+ dt.updateDragImage(tab, dragImageOffset, dragImageOffset);
};
} else {
// Create a panel to use it in setDragImage
@@ -1112,7 +1148,7 @@
@@ -1093,7 +1128,6 @@
);
dragImageOffset = dragImageOffset * scale;
}
- dt.setDragImage(toDrag, dragImageOffset, dragImageOffset);
// _dragData.offsetX/Y give the coordinates that the mouse should be
// positioned relative to the corner of the new window created upon
@@ -1112,7 +1146,7 @@
let dropEffect = this.getDropEffectForTabDrag(event);
let isMovingInTabStrip = !fromTabList && dropEffect == "move";
let collapseTabGroupDuringDrag =
@@ -193,7 +200,7 @@ index 97b931c3c7385a52d20204369fcf6d6999053687..13e872a6c11061b5d7e669476072075d
tab._dragData = {
offsetX: this._tabbrowserTabs.verticalMode
@@ -1122,7 +1158,7 @@
@@ -1122,7 +1156,7 @@
? event.screenY - window.screenY - tabOffset
: event.screenY - window.screenY,
scrollPos:
@@ -202,7 +209,7 @@ index 97b931c3c7385a52d20204369fcf6d6999053687..13e872a6c11061b5d7e669476072075d
? this._tabbrowserTabs.pinnedTabsContainer.scrollPosition
: this._tabbrowserTabs.arrowScrollbox.scrollPosition,
screenX: event.screenX,
@@ -1149,6 +1185,7 @@
@@ -1149,6 +1183,7 @@
if (collapseTabGroupDuringDrag) {
tab.group.collapsed = true;
@@ -210,7 +217,7 @@ index 97b931c3c7385a52d20204369fcf6d6999053687..13e872a6c11061b5d7e669476072075d
}
}
}
@@ -1173,6 +1210,16 @@
@@ -1173,6 +1208,16 @@
if (tabStripItemElement.hasAttribute("dragtarget")) {
return;
}
@@ -227,7 +234,7 @@ index 97b931c3c7385a52d20204369fcf6d6999053687..13e872a6c11061b5d7e669476072075d
let isPinned = tab.pinned;
let numPinned = gBrowser.pinnedTabCount;
let allTabs = this._tabbrowserTabs.ariaFocusableItems;
@@ -1624,10 +1671,7 @@
@@ -1624,10 +1669,7 @@
return;
}
@@ -239,7 +246,7 @@ index 97b931c3c7385a52d20204369fcf6d6999053687..13e872a6c11061b5d7e669476072075d
let directionX = screenX > dragData.animLastScreenX;
let directionY = screenY > dragData.animLastScreenY;
@@ -1636,6 +1680,8 @@
@@ -1636,6 +1678,8 @@
let { width: tabWidth, height: tabHeight } =
draggedTab.getBoundingClientRect();
@@ -248,7 +255,7 @@ index 97b931c3c7385a52d20204369fcf6d6999053687..13e872a6c11061b5d7e669476072075d
let shiftSizeX = tabWidth * movingTabs.length;
let shiftSizeY = tabHeight;
dragData.tabWidth = tabWidth;
@@ -1672,8 +1718,8 @@
@@ -1672,8 +1716,8 @@
let lastBoundX =
lastTabInRow.screenX +
lastTabInRow.getBoundingClientRect().width -
@@ -259,7 +266,7 @@ index 97b931c3c7385a52d20204369fcf6d6999053687..13e872a6c11061b5d7e669476072075d
translateX = Math.min(Math.max(translateX, firstBoundX), lastBoundX);
translateY = Math.min(Math.max(translateY, firstBoundY), lastBoundY);
@@ -2417,6 +2463,7 @@
@@ -2417,6 +2461,7 @@
}
finishAnimateTabMove() {
@@ -267,7 +274,7 @@ index 97b931c3c7385a52d20204369fcf6d6999053687..13e872a6c11061b5d7e669476072075d
if (!this.#isMovingTab()) {
return;
}
@@ -2457,7 +2504,7 @@
@@ -2457,7 +2502,7 @@
tab.style.left = "";
tab.style.top = "";
tab.style.maxWidth = "";
@@ -276,7 +283,7 @@ index 97b931c3c7385a52d20204369fcf6d6999053687..13e872a6c11061b5d7e669476072075d
}
for (let label of draggedTabDocument.getElementsByClassName(
"tab-group-label-container"
@@ -2467,7 +2514,7 @@
@@ -2467,7 +2512,7 @@
label.style.left = "";
label.style.top = "";
label.style.maxWidth = "";

View File

@@ -32,6 +32,9 @@
* @returns {MozTabbrowserTab|vbox}
*/
const elementToMove = (element) => {
if (element.classList.contains('zen-current-workspace-indicator')) {
return element;
}
if (element.group?.hasAttribute('split-view-group')) {
return element.group;
}
@@ -45,6 +48,9 @@
};
window.ZenDragAndDrop = class extends window.TabDragAndDrop {
#dragOverBackground = null;
#lastDropTarget = null;
constructor(tabbrowserTabs) {
super(tabbrowserTabs);
}
@@ -54,7 +60,6 @@
let dt = event.dataTransfer;
const { offsetX, offsetY } = this.#getDragImageOffset(tab);
dt.updateDragImage(tab, offsetX, offsetY);
}
_animateTabMove(event) {
@@ -399,7 +404,36 @@
}
}
this.#applyDragoverIndicator(translate, dropElement, draggedTab);
this._tabbrowserTabs.removeAttribute('movingtab-group');
this._resetGroupTarget(document.querySelector('[dragover-groupTarget]'));
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
) {
// 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)) {
// 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);
this.#applyDragoverIndicator(event, tabs, movingTabs, overlapPercent);
if (
newDropElementIndex == oldDropElementIndex &&
@@ -414,30 +448,88 @@
dragData.animDropElementIndex = newDropElementIndex;
}
#applyDragoverIndicator(translate, dropElement, draggedTab) {
const separation = 8;
handle_dragend(event) {
super.handle_dragend(event);
this.#removeDragOverBackground();
gZenPinnedTabManager.removeTabContainersDragoverClass();
}
#applyDragOverBackground(element) {
if (this.#dragOverBackground && this.#lastDropTarget === element) {
return false;
}
const margin = 2;
const rect = window.windowUtils.getBoundsWithoutFlushing(element);
this.#dragOverBackground = document.createElement('div');
this.#dragOverBackground.id = 'zen-dragover-background';
this.#dragOverBackground.style.height = `${rect.height - margin * 2}px`;
this.#dragOverBackground.style.top = `${rect.top + margin}px`;
gNavToolbox.appendChild(this.#dragOverBackground);
this.#lastDropTarget = element;
return true;
}
#removeDragOverBackground() {
if (this.#dragOverBackground) {
this.#dragOverBackground.remove();
this.#dragOverBackground = null;
this.#lastDropTarget = null;
}
}
#applyDragoverIndicator(event, tabs, movingTabs, overlapPercent) {
const separation = 4;
const dropZoneSelector = ':is(.tabbrowser-tab, .zen-drop-target, .tab-group-label)';
let shouldPlayHapticFeedback = false;
let dropElement = event.target.closest(dropZoneSelector);
if (!dropElement) {
return;
const numEssentials = gBrowser._numZenEssentials;
const numPinned = gBrowser.pinnedTabCount - numEssentials;
const tabToUse = event.target.closest(dropZoneSelector);
if (!tabToUse) {
this.#removeDragOverBackground();
gZenPinnedTabManager.removeTabContainersDragoverClass();
return;
}
const isPinned = tabToUse.pinned;
const relativeTabs = tabs.slice(isPinned ? 0 : numPinned, isPinned ? numPinned : undefined);
const draggedTabRect = elementToMove(tabToUse).getBoundingClientRect();
dropElement = event.clientY > draggedTabRect.top ? relativeTabs.at(-1) : relativeTabs[0];
}
translate += draggedTab._dragData.screenY;
let rect = elementToMove(dropElement).getBoundingClientRect();
const indicator = gZenPinnedTabManager.dragIndicator;
const halfSize = rect.height / 2;
let top = 0;
if (translate >= rect.top + halfSize) {
top = Math.round(rect.top + rect.height) + 'px';
} else {
top = Math.round(rect.top) + 'px';
dropElement = elementToMove(dropElement);
if (this.#lastDropTarget !== dropElement) {
shouldPlayHapticFeedback = this.#lastDropTarget !== null;
this.#removeDragOverBackground();
}
if (indicator.style.top !== top) {
shouldPlayHapticFeedback = true;
let canHightlightGroup =
gZenFolders.highlightGroupOnDragOver(dropElement.parentElement, movingTabs) ||
!dropElement.parentElement?.isZenFolder;
if (isTab(dropElement)) {
const indicator = gZenPinnedTabManager.dragIndicator;
let rect = dropElement.getBoundingClientRect();
let top = 0;
const threshold =
Services.prefs.getIntPref('browser.tabs.dragDrop.moveOverThresholdPercent') / 100;
if (overlapPercent > threshold) {
top = Math.round(rect.top + rect.height) + 'px';
} else {
top = Math.round(rect.top) + 'px';
}
if (indicator.style.top !== top) {
shouldPlayHapticFeedback = true;
}
indicator.setAttribute('orientation', 'horizontal');
indicator.style.setProperty('--indicator-left', rect.left + separation / 2 + 'px');
indicator.style.setProperty('--indicator-width', rect.width - separation + 'px');
indicator.style.top = top;
indicator.style.removeProperty('left');
} else if (dropElement.classList.contains('zen-drop-target') && canHightlightGroup) {
// removeTabContainersDragoverClass Already calls a new haptic feedback
shouldPlayHapticFeedback =
this.#applyDragOverBackground(dropElement) && !gZenPinnedTabManager._dragIndicator;
gZenPinnedTabManager.removeTabContainersDragoverClass();
}
indicator.setAttribute('orientation', 'horizontal');
indicator.style.setProperty('--indicator-left', rect.left + separation / 2 + 'px');
indicator.style.setProperty('--indicator-width', rect.width - separation + 'px');
indicator.style.top = top;
indicator.style.removeProperty('left');
if (shouldPlayHapticFeedback) {
Services.zen.playHapticFeedback();
}

View File

@@ -6,7 +6,7 @@ class ZenFolder extends MozTabbrowserTabGroup {
#initialized = false;
static markup = `
<hbox class="tab-group-label-container" pack="center">
<hbox class="tab-group-label-container zen-drop-target" pack="center">
<html:div class="tab-group-folder-icon"/>
<label class="tab-group-label" role="button"/>
<image class="tab-reset-button reset-icon" role="button" keyNav="false" data-l10n-id="zen-folders-unload-all-tooltip"/>

View File

@@ -37,6 +37,7 @@ class nsZenFolders extends nsZenDOMOperatedFeature {
#popup = null;
#popupTimer = null;
#mouseTimer = null;
#lastHighlightedGroup = null;
#lastFolderContextMenu = null;
@@ -97,7 +98,7 @@ class nsZenFolders extends nsZenDOMOperatedFeature {
.getElementById('context_zenChangeFolderSpace')
.querySelector('menupopup');
changeFolderSpace.innerHTML = '';
for (const workspace of [...gZenWorkspaces._workspaceCache.workspaces].reverse()) {
for (const workspace of [...gZenWorkspaces.getWorkspaces()].reverse()) {
const item = gZenWorkspaces.generateMenuItemForWorkspace(workspace);
item.addEventListener('command', (event) => {
if (!this.#lastFolderContextMenu) return;
@@ -1056,6 +1057,38 @@ class nsZenFolders extends nsZenDOMOperatedFeature {
this._sessionRestoring = false;
}
/**
* Highlights the given tab group and removes highlight from any previously highlighted group.
* @param {MozTabbrowserTabGroup|undefined|null} folder The folder to highlight, or null to clear highlight.
* @param {Array<MozTabbrowserTab>|null} movingTabs The tabs being moved.
*/
highlightGroupOnDragOver(folder, movingTabs) {
if (folder === this.#lastHighlightedGroup) return true;
const tab = movingTabs ? movingTabs[0] : null;
if (this.#lastHighlightedGroup && this.#lastHighlightedGroup !== folder) {
if (this.#lastHighlightedGroup.collapsed) {
this.updateFolderIcon(this.#lastHighlightedGroup, 'close');
}
this.#lastHighlightedGroup = null;
}
if (
folder?.isZenFolder &&
(!folder.hasAttribute('split-view-group') || !folder.hasAttribute('selected')) &&
folder !== tab?.group &&
!(
folder.level >= this.#ZEN_MAX_SUBFOLDERS &&
movingTabs?.some((t) => gBrowser.isTabGroupLabel(t))
)
) {
if (folder.collapsed) {
this.updateFolderIcon(folder, 'open');
}
this.#lastHighlightedGroup = folder;
return true;
}
return false;
}
/**
* Ungroup a tab from all the active groups it belongs to.
* @param {MozTabbrowserTab[]} tabs The tab to ungroup.

View File

@@ -503,15 +503,16 @@ class nsZenPinnedTabManager extends nsZenDOMOperatedFeature {
}
moveToAnotherTabContainerIfNecessary(event, movingTabs) {
movingTabs = [...movingTabs];
if (!this.enabled) {
return false;
}
movingTabs = [...movingTabs];
try {
const pinnedTabsTarget =
event.target.closest('.zen-current-workspace-indicator') || this._isGoingToPinnedTabs;
const pinnedTabsTarget = event.target.closest(
':is(.zen-current-workspace-indicator, .zen-workspace-pinned-tabs-section)'
);
const essentialTabsTarget = event.target.closest('.zen-essentials-container');
const tabsTarget = !this._isGoingToPinnedTabs;
const tabsTarget = !pinnedTabsTarget;
// TODO: Solve the issue of adding a tab between two groups
// Remove group labels from the moving tabs and replace it

View File

@@ -1281,9 +1281,9 @@
width: calc(var(--indicator-width) - 2 * var(--zen-drag-indicator-height) - 4px);
height: var(--zen-drag-indicator-height);
transition:
top 0.1s ease-out,
left 0.1s ease-out,
width 0.1s ease-out;
top 0.05s ease-out,
left 0.05s ease-out,
width 0.05s ease-out;
&::before {
left: calc(-2 * var(--zen-drag-indicator-height));
@@ -1370,3 +1370,13 @@
.tab-group-label-container[zen-dragtarget] {
z-index: 9 !important;
}
#zen-dragover-background {
position: absolute;
z-index: -1;
/* Extra width to cover the sidebar splitter */
width: calc(100% + var(--zen-toolbox-padding));
left: 0;
pointer-events: none;
background: var(--zen-primary-color);
}

View File

@@ -5,7 +5,7 @@
class nsZenWorkspace extends MozXULElement {
static get markup() {
return `
<vbox class="zen-workspace-tabs-section zen-current-workspace-indicator" flex="1" context="zenWorkspaceMoreActions">
<vbox class="zen-workspace-tabs-section zen-current-workspace-indicator zen-drop-target" flex="1" context="zenWorkspaceMoreActions">
<hbox class="zen-current-workspace-indicator-icon" />
<label class="zen-current-workspace-indicator-name" flex="1" />
<toolbarbutton class="toolbarbutton-1 chromeclass-toolbar-additional zen-workspaces-actions" context="zenWorkspaceMoreActions" />