From 04833ad0900d49c6a4b4e5fdf438e47e22d4cd37 Mon Sep 17 00:00:00 2001 From: "mr. m" Date: Tue, 16 Dec 2025 14:01:44 +0100 Subject: [PATCH] feat: Add support for drag-and-dropping tabs into groups, b=no-bug, c=common, folders, tabs, workspaces --- .../tabbrowser/content/drag-and-drop-js.patch | 33 +++-- src/zen/common/ZenDragAndDrop.js | 134 +++++++++++++++--- src/zen/folders/ZenFolder.mjs | 2 +- src/zen/folders/ZenFolders.mjs | 35 ++++- src/zen/tabs/ZenPinnedTabManager.mjs | 9 +- src/zen/tabs/zen-tabs/vertical-tabs.css | 16 ++- src/zen/workspaces/ZenWorkspace.mjs | 2 +- 7 files changed, 187 insertions(+), 44 deletions(-) diff --git a/src/browser/components/tabbrowser/content/drag-and-drop-js.patch b/src/browser/components/tabbrowser/content/drag-and-drop-js.patch index e29be922a..d15be39c7 100644 --- a/src/browser/components/tabbrowser/content/drag-and-drop-js.patch +++ b/src/browser/components/tabbrowser/content/drag-and-drop-js.patch @@ -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 = ""; diff --git a/src/zen/common/ZenDragAndDrop.js b/src/zen/common/ZenDragAndDrop.js index 530d27d3f..5cdc611f6 100644 --- a/src/zen/common/ZenDragAndDrop.js +++ b/src/zen/common/ZenDragAndDrop.js @@ -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(); } diff --git a/src/zen/folders/ZenFolder.mjs b/src/zen/folders/ZenFolder.mjs index 6c1d2dbc7..54fe7d949 100644 --- a/src/zen/folders/ZenFolder.mjs +++ b/src/zen/folders/ZenFolder.mjs @@ -6,7 +6,7 @@ class ZenFolder extends MozTabbrowserTabGroup { #initialized = false; static markup = ` - +