From f044433bd113efdb42c42cb74c11aa9f7cb765dc Mon Sep 17 00:00:00 2001 From: "mr. m" Date: Tue, 16 Dec 2025 01:50:41 +0100 Subject: [PATCH] feat: New drag and drop system, b=no-bug, c=tabs, common, folders --- prefs/zen/view.yaml | 6 - .../tabbrowser/content/drag-and-drop-js.patch | 218 ++------- .../tabbrowser/content/tabs-js.patch | 11 +- src/zen/common/ZenDragAndDrop.js | 455 ++++++++++++++++++ src/zen/common/ZenPreloadedScripts.js | 2 + src/zen/common/jar.inc.mn | 1 + src/zen/folders/ZenFolders.mjs | 86 ---- src/zen/tabs/ZenPinnedTabManager.mjs | 50 -- 8 files changed, 501 insertions(+), 328 deletions(-) create mode 100644 src/zen/common/ZenDragAndDrop.js diff --git a/prefs/zen/view.yaml b/prefs/zen/view.yaml index 7f5939822..b75ad9e88 100644 --- a/prefs/zen/view.yaml +++ b/prefs/zen/view.yaml @@ -40,12 +40,6 @@ - name: zen.view.window.scheme value: 2 -- name: zen.view.drag-and-drop.move-over-threshold - value: 70 - -- name: zen.view.drag-and-drop.edge-zone-threshold - value: 25 - - name: zen.view.context-menu.refresh value: false 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 3cc560c34..e29be922a 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..bc49f4f5a90638d725eca016d00f30d9548dce83 100644 +index 97b931c3c7385a52d20204369fcf6d6999053687..13e872a6c11061b5d7e669476072075d0e685eb9 100644 --- a/browser/components/tabbrowser/content/drag-and-drop.js +++ b/browser/components/tabbrowser/content/drag-and-drop.js @@ -32,6 +32,9 @@ @@ -12,18 +12,17 @@ index 97b931c3c7385a52d20204369fcf6d6999053687..bc49f4f5a90638d725eca016d00f30d9 if (isTab(element)) { return element; } -@@ -112,6 +115,10 @@ +@@ -112,6 +115,9 @@ } let draggedTab = event.dataTransfer.mozGetDataAt(TAB_DROP_TYPE, 0); + if (draggedTab && dropEffect === "move") { -+ gZenPinnedTabManager.applyDragoverClass(event, draggedTab); + gZenViewSplitter.onBrowserDragEndToSplit(event); + } if ( (dropEffect == "move" || dropEffect == "copy") && document == draggedTab.ownerDocument && -@@ -266,6 +273,18 @@ +@@ -266,6 +272,18 @@ this._tabDropIndicator.hidden = true; event.stopPropagation(); @@ -42,7 +41,7 @@ index 97b931c3c7385a52d20204369fcf6d6999053687..bc49f4f5a90638d725eca016d00f30d9 if (draggedTab && dropEffect == "copy") { let duplicatedDraggedTab; let duplicatedTabs = []; -@@ -291,8 +310,9 @@ +@@ -291,8 +309,9 @@ let translateOffsetY = oldTranslateY % tabHeight; let newTranslateX = oldTranslateX - translateOffsetX; let newTranslateY = oldTranslateY - translateOffsetY; @@ -54,7 +53,7 @@ index 97b931c3c7385a52d20204369fcf6d6999053687..bc49f4f5a90638d725eca016d00f30d9 if (this._isContainerVerticalPinnedGrid(draggedTab)) { // Update both translate axis for pinned vertical expanded tabs -@@ -308,8 +328,8 @@ +@@ -308,8 +327,8 @@ } } else { let tabs = this._tabbrowserTabs.ariaFocusableItems.slice( @@ -65,7 +64,7 @@ index 97b931c3c7385a52d20204369fcf6d6999053687..bc49f4f5a90638d725eca016d00f30d9 ); let size = this._tabbrowserTabs.verticalMode ? "height" : "width"; let screenAxis = this._tabbrowserTabs.verticalMode -@@ -362,11 +382,13 @@ +@@ -362,11 +381,13 @@ this._dragToPinPromoCard, ]; let shouldPin = @@ -79,7 +78,7 @@ index 97b931c3c7385a52d20204369fcf6d6999053687..bc49f4f5a90638d725eca016d00f30d9 isTab(draggedTab) && draggedTab.pinned && this._tabbrowserTabs.arrowScrollbox.contains(event.target); -@@ -384,6 +406,7 @@ +@@ -384,6 +405,7 @@ (oldTranslateY && oldTranslateY != newTranslateY); } else if (this._tabbrowserTabs.verticalMode) { shouldTranslate &&= oldTranslateY && oldTranslateY != newTranslateY; @@ -87,7 +86,7 @@ index 97b931c3c7385a52d20204369fcf6d6999053687..bc49f4f5a90638d725eca016d00f30d9 } else { shouldTranslate &&= oldTranslateX && oldTranslateX != newTranslateX; } -@@ -440,7 +463,7 @@ +@@ -440,7 +462,7 @@ item.removeAttribute("tabdrop-samewindow"); resolve(); }; @@ -96,7 +95,7 @@ index 97b931c3c7385a52d20204369fcf6d6999053687..bc49f4f5a90638d725eca016d00f30d9 postTransitionCleanup(); } else { let onTransitionEnd = transitionendEvent => { -@@ -581,6 +604,7 @@ +@@ -581,6 +603,7 @@ let nextItem = this._tabbrowserTabs.ariaFocusableItems[newIndex]; let tabGroup = isTab(nextItem) && nextItem.group; @@ -104,7 +103,7 @@ index 97b931c3c7385a52d20204369fcf6d6999053687..bc49f4f5a90638d725eca016d00f30d9 gBrowser.loadTabs(urls, { inBackground, replace, -@@ -618,7 +642,16 @@ +@@ -618,7 +641,16 @@ this._expandGroupOnDrop(draggedTab); } this._resetTabsAfterDrop(draggedTab.ownerDocument); @@ -122,7 +121,7 @@ index 97b931c3c7385a52d20204369fcf6d6999053687..bc49f4f5a90638d725eca016d00f30d9 if ( dt.mozUserCancelled || dt.dropEffect != "none" || -@@ -822,7 +855,10 @@ +@@ -822,7 +854,10 @@ _getDragTarget(event, { ignoreSides = false } = {}) { let { target } = event; while (target) { @@ -134,7 +133,7 @@ index 97b931c3c7385a52d20204369fcf6d6999053687..bc49f4f5a90638d725eca016d00f30d9 break; } target = target.parentNode; -@@ -839,14 +875,17 @@ +@@ -839,14 +874,17 @@ return null; } } @@ -154,7 +153,7 @@ index 97b931c3c7385a52d20204369fcf6d6999053687..bc49f4f5a90638d725eca016d00f30d9 !this._tabbrowserTabs.expandOnHover ); } -@@ -877,7 +916,8 @@ +@@ -877,7 +915,8 @@ isTabGroupLabel(draggedTab) && draggedTab._dragData?.expandGroupOnDrop ) { @@ -164,7 +163,7 @@ index 97b931c3c7385a52d20204369fcf6d6999053687..bc49f4f5a90638d725eca016d00f30d9 } } -@@ -942,10 +982,7 @@ +@@ -942,10 +981,7 @@ if (this._isContainerVerticalPinnedGrid(tab)) { // In expanded vertical mode, the max number of pinned tabs per row is dynamic // Set this before adjusting dragged tab's position @@ -176,7 +175,16 @@ index 97b931c3c7385a52d20204369fcf6d6999053687..bc49f4f5a90638d725eca016d00f30d9 let tabsPerRow = 0; let position = RTL_UI ? window.windowUtils.getBoundsWithoutFlushing( -@@ -1112,7 +1149,7 @@ +@@ -1055,7 +1091,7 @@ + // 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 @@ let dropEffect = this.getDropEffectForTabDrag(event); let isMovingInTabStrip = !fromTabList && dropEffect == "move"; let collapseTabGroupDuringDrag = @@ -185,7 +193,7 @@ index 97b931c3c7385a52d20204369fcf6d6999053687..bc49f4f5a90638d725eca016d00f30d9 tab._dragData = { offsetX: this._tabbrowserTabs.verticalMode -@@ -1122,7 +1159,7 @@ +@@ -1122,7 +1158,7 @@ ? event.screenY - window.screenY - tabOffset : event.screenY - window.screenY, scrollPos: @@ -194,7 +202,7 @@ index 97b931c3c7385a52d20204369fcf6d6999053687..bc49f4f5a90638d725eca016d00f30d9 ? this._tabbrowserTabs.pinnedTabsContainer.scrollPosition : this._tabbrowserTabs.arrowScrollbox.scrollPosition, screenX: event.screenX, -@@ -1149,6 +1186,7 @@ +@@ -1149,6 +1185,7 @@ if (collapseTabGroupDuringDrag) { tab.group.collapsed = true; @@ -202,7 +210,7 @@ index 97b931c3c7385a52d20204369fcf6d6999053687..bc49f4f5a90638d725eca016d00f30d9 } } } -@@ -1173,6 +1211,16 @@ +@@ -1173,6 +1210,16 @@ if (tabStripItemElement.hasAttribute("dragtarget")) { return; } @@ -219,7 +227,7 @@ index 97b931c3c7385a52d20204369fcf6d6999053687..bc49f4f5a90638d725eca016d00f30d9 let isPinned = tab.pinned; let numPinned = gBrowser.pinnedTabCount; let allTabs = this._tabbrowserTabs.ariaFocusableItems; -@@ -1624,10 +1672,7 @@ +@@ -1624,10 +1671,7 @@ return; } @@ -231,7 +239,7 @@ index 97b931c3c7385a52d20204369fcf6d6999053687..bc49f4f5a90638d725eca016d00f30d9 let directionX = screenX > dragData.animLastScreenX; let directionY = screenY > dragData.animLastScreenY; -@@ -1636,6 +1681,8 @@ +@@ -1636,6 +1680,8 @@ let { width: tabWidth, height: tabHeight } = draggedTab.getBoundingClientRect(); @@ -240,7 +248,7 @@ index 97b931c3c7385a52d20204369fcf6d6999053687..bc49f4f5a90638d725eca016d00f30d9 let shiftSizeX = tabWidth * movingTabs.length; let shiftSizeY = tabHeight; dragData.tabWidth = tabWidth; -@@ -1672,8 +1719,8 @@ +@@ -1672,8 +1718,8 @@ let lastBoundX = lastTabInRow.screenX + lastTabInRow.getBoundingClientRect().width - @@ -251,161 +259,7 @@ index 97b931c3c7385a52d20204369fcf6d6999053687..bc49f4f5a90638d725eca016d00f30d9 translateX = Math.min(Math.max(translateX, firstBoundX), lastBoundX); translateY = Math.min(Math.max(translateY, firstBoundY), lastBoundY); -@@ -1833,13 +1880,18 @@ - this._clearDragOverGroupingTimer(); - this.#clearPinnedDropIndicatorTimer(); - -- let isPinned = draggedTab.pinned; -- let numPinned = gBrowser.pinnedTabCount; -+ let isPinned = draggedTab?.group ? draggedTab.group.pinned : draggedTab.pinned; -+ let numPinned = gBrowser._numVisiblePinTabsWithoutCollapsed; -+ let essential = draggedTab.hasAttribute("zen-essential"); -+ const isDraggingFolder = isTabGroupLabel(draggedTab) && draggedTab.group?.isZenFolder; - let allTabs = this._tabbrowserTabs.ariaFocusableItems; - let tabs = allTabs.slice( -- isPinned ? 0 : numPinned, -- isPinned ? numPinned : undefined -+ (isPinned && essential) ? 0 : gBrowser._numZenEssentials, -+ isPinned ? (essential ? gBrowser._numZenEssentials : (isDraggingFolder ? numPinned : undefined)) : undefined - ); -+ if (draggedTab.group?.hasAttribute("split-view-group")) { -+ draggedTab = draggedTab.group.labelElement; -+ } - - if (this._rtlMode) { - tabs.reverse(); -@@ -1854,7 +1906,7 @@ - let translateAxis = this._tabbrowserTabs.verticalMode - ? "translateY" - : "translateX"; -- let { width: tabWidth, height: tabHeight } = bounds(draggedTab); -+ let { width: tabWidth, height: tabHeight } = bounds(draggedTab.group?.hasAttribute("split-view-group") ? draggedTab.group : draggedTab); - let tabSize = this._tabbrowserTabs.verticalMode ? tabHeight : tabWidth; - let translateX = event.screenX - dragData.screenX; - let translateY = event.screenY - dragData.screenY; -@@ -1870,6 +1922,12 @@ - ); - let lastMovingTab = movingTabs.at(-1); - let firstMovingTab = movingTabs[0]; -+ if (lastMovingTab.group?.hasAttribute("split-view-group")) { -+ lastMovingTab = lastMovingTab.group; -+ } -+ if (firstMovingTab.group?.hasAttribute("split-view-group")) { -+ firstMovingTab = firstMovingTab.group; -+ } - let endEdge = ele => ele[screenAxis] + bounds(ele)[size]; - let lastMovingTabScreen = endEdge(lastMovingTab); - let firstMovingTabScreen = firstMovingTab[screenAxis]; -@@ -1884,6 +1942,13 @@ - let endBound = this._rtlMode - ? endEdge(this._tabbrowserTabs) - lastMovingTabScreen - : periphery[screenAxis] - 1 - lastMovingTabScreen; -+ { -+ let firstTab = tabs.at(this._rtlMode ? -1 : 0); -+ let lastTab = tabs.at(this._rtlMode ? 0 : -1); -+ startBound = firstTab[screenAxis] - firstMovingTabScreen; -+ endBound = endEdge(lastTab) - lastMovingTabScreen; -+ endBound = gZenPinnedTabManager.getLastTabBound(endBound, lastTab, isDraggingFolder); -+ } - translate = Math.min(Math.max(translate, startBound), endBound); - - // Center the tab under the cursor if the tab is not under the cursor while dragging -@@ -2075,6 +2140,8 @@ - }; - - let dropElement = getOverlappedElement(); -+ if (dropElement?.hasAttribute("split-view-group")) dropElement = dropElement.labelElement; -+ gZenPinnedTabManager.animateSeparatorMove(movingTabs, dropElement, isPinned, event); - - let newDropElementIndex; - if (dropElement) { -@@ -2157,7 +2224,7 @@ - ? Services.prefs.getIntPref( - "browser.tabs.dragDrop.moveOverThresholdPercent" - ) / 100 -- : 0.5; -+ : Services.prefs.getIntPref('zen.view.drag-and-drop.move-over-threshold') / 100; - moveOverThreshold = Math.min(1, Math.max(0, moveOverThreshold)); - let shouldMoveOver = overlapPercent > moveOverThreshold; - if (logicalForward && shouldMoveOver) { -@@ -2190,6 +2257,7 @@ - // 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 ( -+ false && - isTabGroupLabel(draggedTab) && - dropElement?.group && - (!dropElement.group.collapsed || -@@ -2216,20 +2284,13 @@ - 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) -- ) { -+ if (isTab(draggedTab) || isTabGroupLabel(draggedTab)) { - let dragOverGroupingThreshold = 1 - moveOverThreshold; -+ if (draggedTab && !dropElement?.group) { -+ gZenFolders.highlightGroupOnDragOver(null); -+ } - let groupingDelay = Services.prefs.getIntPref( - "browser.tabs.dragDrop.createGroup.delayMS" - ); -@@ -2237,6 +2298,7 @@ - // 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 = -+ false && - !movingTabsSet.has(dropElement) && - isTab(dropElement) && - !dropElement?.group && -@@ -2245,6 +2307,7 @@ - // 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 = -+ false && - isTabGroupLabel(dropElement) && - dropElement.group.collapsed && - overlapPercent > dragOverGroupingThreshold; -@@ -2302,6 +2365,14 @@ - dropElement = dropElementGroup.tabs[0]; - dropBefore = true; - } -+ ({ dropElement, colorCode, dropBefore } = gZenFolders.handleDragOverTabGroupLabel( -+ dropElement, -+ draggedTab, -+ overlapPercent, -+ movingTabs, -+ dropBefore, -+ colorCode -+ )); - } - this._setDragOverGroupColor(colorCode); - this._tabbrowserTabs.toggleAttribute( -@@ -2324,10 +2395,11 @@ - dragData.dropBefore = dropBefore; - dragData.animDropElementIndex = newDropElementIndex; - -+ gZenFolders.setFolderIndentation(movingTabs, dropElement); - // Shift background tabs to leave a gap where the dragged tab - // would currently be dropped. - for (let item of tabs) { -- if (item == draggedTab) { -+ if (item == draggedTab || (item.group?.hasAttribute("split-view-group") && item.group == draggedTab.group)) { - continue; - } - -@@ -2417,11 +2489,13 @@ +@@ -2417,6 +2463,7 @@ } finishAnimateTabMove() { @@ -413,13 +267,7 @@ index 97b931c3c7385a52d20204369fcf6d6999053687..bc49f4f5a90638d725eca016d00f30d9 if (!this.#isMovingTab()) { return; } - - this.#setMovingTabMode(false); -+ gZenFolders.highlightGroupOnDragOver(null); - - for (let item of this._tabbrowserTabs.ariaFocusableItems) { - this._resetGroupTarget(item); -@@ -2457,7 +2531,7 @@ +@@ -2457,7 +2504,7 @@ tab.style.left = ""; tab.style.top = ""; tab.style.maxWidth = ""; @@ -428,7 +276,7 @@ index 97b931c3c7385a52d20204369fcf6d6999053687..bc49f4f5a90638d725eca016d00f30d9 } for (let label of draggedTabDocument.getElementsByClassName( "tab-group-label-container" -@@ -2467,7 +2541,7 @@ +@@ -2467,7 +2514,7 @@ label.style.left = ""; label.style.top = ""; label.style.maxWidth = ""; diff --git a/src/browser/components/tabbrowser/content/tabs-js.patch b/src/browser/components/tabbrowser/content/tabs-js.patch index ef5bb94e3..3b660b333 100644 --- a/src/browser/components/tabbrowser/content/tabs-js.patch +++ b/src/browser/components/tabbrowser/content/tabs-js.patch @@ -1,7 +1,16 @@ diff --git a/browser/components/tabbrowser/content/tabs.js b/browser/components/tabbrowser/content/tabs.js -index 6b6c04599fe80983d13d2069ca62b99d8ad70271..a765f2decc3a565226ac8793422474052f476573 100644 +index 6b6c04599fe80983d13d2069ca62b99d8ad70271..009a9c398e2434b8b6704ed2c75b0f09ecc22ca1 100644 --- a/browser/components/tabbrowser/content/tabs.js +++ b/browser/components/tabbrowser/content/tabs.js +@@ -235,7 +235,7 @@ + true + ) + ? new window.TabStacking(this) +- : new window.TabDragAndDrop(this); ++ : new window.ZenDragAndDrop(this); + this.tabDragAndDrop.init(); + } + @@ -436,7 +436,7 @@ // and we're not hitting the scroll buttons. if ( diff --git a/src/zen/common/ZenDragAndDrop.js b/src/zen/common/ZenDragAndDrop.js new file mode 100644 index 000000000..530d27d3f --- /dev/null +++ b/src/zen/common/ZenDragAndDrop.js @@ -0,0 +1,455 @@ +/* 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: + * + * - (.tabbrowser-tab) + * - label element (.tab-group-label) + * + * The elements in the tab strip that contain the space inside of the + * element are: + * + * - (.tabbrowser-tab) + * - 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 (element.group?.hasAttribute('split-view-group')) { + return element.group; + } + 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.ZenDragAndDrop = class extends window.TabDragAndDrop { + constructor(tabbrowserTabs) { + super(tabbrowserTabs); + } + + startTabDrag(event, tab, ...args) { + super.startTabDrag(event, tab, ...args); + let dt = event.dataTransfer; + + const { offsetX, offsetY } = this.#getDragImageOffset(tab); + dt.updateDragImage(tab, offsetX, offsetY); + } + + _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 allTabs = this._tabbrowserTabs.ariaFocusableItems; + let numEssentials = gBrowser._numZenEssentials; + let isEssential = draggedTab.hasAttribute('zen-essential'); + let tabs = allTabs.slice( + isEssential ? 0 : numEssentials, + isEssential ? numEssentials : undefined + ); + + let screen = this._tabbrowserTabs.verticalMode ? event.screenY : event.screenX; + if (screen == dragData.animLastScreenPos) { + return; + } + let screenForward = screen > dragData.animLastScreenPos; + dragData.animLastScreenPos = screen; + + this._clearDragOverGroupingTimer(); + + 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 { width: tabWidth, height: tabHeight } = bounds(draggedTab); + let tabSize = this._tabbrowserTabs.verticalMode ? tabHeight : tabWidth; + let translateX = event.screenX - dragData.screenX; + let translateY = event.screenY - dragData.screenY; + + dragData.tabWidth = tabWidth; + dragData.tabHeight = tabHeight; + dragData.translateX = translateX; + dragData.translateY = translateY; + + // Move the dragged tab based on the mouse position. + let periphery = document.getElementById('tabbrowser-arrowscrollbox-periphery'); + let lastMovingTab = movingTabs.at(-1); + let firstMovingTab = movingTabs[0]; + let endEdge = (ele) => ele[screenAxis] + bounds(ele)[size]; + let lastMovingTabScreen = endEdge(lastMovingTab); + let firstMovingTabScreen = firstMovingTab[screenAxis]; + let shiftSize = lastMovingTabScreen - firstMovingTabScreen; + 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 - firstMovingTabScreen + : this._tabbrowserTabs[screenAxis] - firstMovingTabScreen; + let endBound = this._rtlMode + ? endEdge(this._tabbrowserTabs) - lastMovingTabScreen + : periphery[screenAxis] - 1 - lastMovingTabScreen; + let firstTab = tabs.at(this._rtlMode ? -1 : 0); + let lastTab = tabs.at(this._rtlMode ? 0 : -1); + startBound = firstTab[screenAxis] - firstMovingTabScreen; + endBound = endEdge(lastTab) - lastMovingTabScreen; + 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, + lastMovingTabScreen, + pinnedTabsStartEdge: this._rtlMode + ? endEdge(this._tabbrowserTabs.arrowScrollbox) + pinnedDropIndicatorMargin + : this[screenAxis], + pinnedTabsEndEdge: this._rtlMode + ? endEdge(this._tabbrowserTabs) + : this._tabbrowserTabs.arrowScrollbox[screenAxis] - pinnedDropIndicatorMargin, + translate, + draggedTab, + }); + } + + 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.elementIndex < draggedTab.elementIndex && item.elementIndex >= dropElementIndex) { + return this._rtlMode ? -shiftSize : shiftSize; + } + if (item.elementIndex > draggedTab.elementIndex && item.elementIndex < dropElementIndex) { + return this._rtlMode ? shiftSize : -shiftSize; + } + return 0; + }; + + let oldDropElementIndex = dragData.animDropElementIndex ?? movingTabs[0].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 ? lastMovingTabScreen : firstMovingTabScreen) + 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.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?.elementIndex; + if (Number.isInteger(maxElementIndexForDropElement)) { + let index = Math.min(oldDropElementIndex, maxElementIndexForDropElement); + let oldDropElementCandidate = this._tabbrowserTabs.ariaFocusableItems.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 = firstMovingTabScreen + 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; + } + } + + this.#applyDragoverIndicator(translate, dropElement, draggedTab); + + if ( + newDropElementIndex == oldDropElementIndex && + dropBefore == dragData.dropBefore && + dropElement == dragData.dropElement + ) { + return; + } + + dragData.dropElement = dropElement; + dragData.dropBefore = dropBefore; + dragData.animDropElementIndex = newDropElementIndex; + } + + #applyDragoverIndicator(translate, dropElement, draggedTab) { + const separation = 8; + let shouldPlayHapticFeedback = false; + if (!dropElement) { + return; + } + 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'; + } + 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'); + if (shouldPlayHapticFeedback) { + Services.zen.playHapticFeedback(); + } + } + + #getDragImageOffset(tab) { + const { offsetX, offsetY } = tab._dragData; + const rect = tab.getBoundingClientRect(); + return { + offsetX: offsetX - rect.left, + offsetY: offsetY - rect.top, + }; + } + }; +} diff --git a/src/zen/common/ZenPreloadedScripts.js b/src/zen/common/ZenPreloadedScripts.js index c511febfc..ea62dadb4 100644 --- a/src/zen/common/ZenPreloadedScripts.js +++ b/src/zen/common/ZenPreloadedScripts.js @@ -11,4 +11,6 @@ ChromeUtils.importESModule("chrome://browser/content/zen-components/ZenMods.mjs", { global: "current" }); ChromeUtils.importESModule("chrome://browser/content/zen-components/ZenKeyboardShortcuts.mjs", { global: "current" }); ChromeUtils.importESModule("chrome://browser/content/zen-components/ZenSessionStore.mjs", { global: "current" }); + + Services.scriptloader.loadSubScript("chrome://browser/content/zen-components/ZenDragAndDrop.js", this); } diff --git a/src/zen/common/jar.inc.mn b/src/zen/common/jar.inc.mn index 2d4172f8b..e8f2355c7 100644 --- a/src/zen/common/jar.inc.mn +++ b/src/zen/common/jar.inc.mn @@ -13,6 +13,7 @@ content/browser/zen-components/ZenSessionStore.mjs (../../zen/common/modules/ZenSessionStore.mjs) content/browser/zen-components/ZenHasPolyfill.mjs (../../zen/common/modules/ZenHasPolyfill.mjs) content/browser/zen-components/ZenSidebarNotification.mjs (../../zen/common/modules/ZenSidebarNotification.mjs) + content/browser/zen-components/ZenDragAndDrop.js (../../zen/common/ZenDragAndDrop.js) content/browser/zen-components/ZenEmojisData.min.mjs (../../zen/common/emojis/ZenEmojisData.min.mjs) content/browser/zen-components/ZenEmojiPicker.mjs (../../zen/common/emojis/ZenEmojiPicker.mjs) diff --git a/src/zen/folders/ZenFolders.mjs b/src/zen/folders/ZenFolders.mjs index 01d9f1062..bbf927b2c 100644 --- a/src/zen/folders/ZenFolders.mjs +++ b/src/zen/folders/ZenFolders.mjs @@ -33,13 +33,10 @@ function formatRelativeTime(timestamp) { class nsZenFolders extends nsZenDOMOperatedFeature { #ZEN_MAX_SUBFOLDERS = Services.prefs.getIntPref('zen.folders.max-subfolders', 5); - #ZEN_EDGE_ZONE_THRESHOLD = - Services.prefs.getIntPref('zen.view.drag-and-drop.edge-zone-threshold', 25) / 100; #popup = null; #popupTimer = null; #mouseTimer = null; - #lastHighlightedGroup = null; #lastFolderContextMenu = null; @@ -1059,40 +1056,6 @@ 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|null} movingTabs The tabs being moved. - */ - highlightGroupOnDragOver(folder, movingTabs) { - if (folder === this.#lastHighlightedGroup) return; - const tab = movingTabs ? movingTabs[0] : null; - if (this.#lastHighlightedGroup && this.#lastHighlightedGroup !== folder) { - this.#lastHighlightedGroup.removeAttribute('selected'); - if (this.#lastHighlightedGroup.collapsed) { - this.updateFolderIcon(this.#lastHighlightedGroup, 'close'); - } - this.#lastHighlightedGroup = null; - } - - if ( - folder && - (!folder.hasAttribute('split-view-group') || !folder.hasAttribute('selected')) && - folder !== tab?.group && - !( - folder.level >= this.#ZEN_MAX_SUBFOLDERS && - movingTabs?.some((t) => gBrowser.isTabGroupLabel(t)) - ) - ) { - folder.setAttribute('selected', 'true'); - folder.style.transform = ''; - if (folder.collapsed) { - this.updateFolderIcon(folder, 'open'); - } - this.#lastHighlightedGroup = folder; - } - } - /** * Ungroup a tab from all the active groups it belongs to. * @param {MozTabbrowserTab[]} tabs The tab to ungroup. @@ -1103,55 +1066,6 @@ class nsZenFolders extends nsZenDOMOperatedFeature { } } - /** - * Handles the dragover logic when dragging a tab or tab group label over another tab group label. - * This function determines where the dragged item should be visually dropped (before/after the group, or inside it) - * and updates related styling and highlighting. - * - * @param {MozTabbrowserTabGroupLabel} currentDropElement The tab group label currently being dragged over. - * @param {MozTabbrowserTab|MozTabbrowserTabGroupLabel} draggedTab The tab or tab group label being dragged. - * @param {number} overlapPercent The percentage of overlap between the dragged item and the drop target. - * @param {Array} movingTabs An array of tabs that are currently being dragged together. - * @param {boolean} currentDropBefore Indicates if the current drop position is before the middle of the drop element. - * @param {string|undefined} currentColorCode The current color code for dragover highlighting. - * @returns {{dropElement: MozTabbrowserTabGroup|MozTabbrowserTab|MozTabbrowserTabGroupLabel, colorCode: string|undefined, dropBefore: boolean}} - * An object containing the updated drop element, color code for highlighting, and drop position. - */ - handleDragOverTabGroupLabel( - currentDropElement, - draggedTab, - overlapPercent, - movingTabs, - currentDropBefore, - currentColorCode - ) { - let dropElement = currentDropElement; - let dropBefore = currentDropBefore; - let colorCode = currentColorCode; - - const dropElementGroup = dropElement?.isZenFolder ? dropElement : dropElement?.group; - const isSplitGroup = dropElement?.group?.hasAttribute('split-view-group'); - let firstGroupElem = - dropElementGroup?.querySelector('.zen-tab-group-start')?.nextElementSibling; - if (gBrowser.isTabGroup(firstGroupElem)) firstGroupElem = firstGroupElem.labelElement; - - const isInMiddleZone = - overlapPercent >= this.#ZEN_EDGE_ZONE_THRESHOLD && - overlapPercent <= 1 - this.#ZEN_EDGE_ZONE_THRESHOLD; - const shouldDropInside = isInMiddleZone && !isSplitGroup; - - if (shouldDropInside) { - dropElement = firstGroupElem; - dropBefore = true; - this.highlightGroupOnDragOver(dropElementGroup, movingTabs); - } else { - colorCode = undefined; - this.highlightGroupOnDragOver(null); - } - - return { dropElement, colorCode, dropBefore }; - } - #normalizeGroupItems(items) { return items .filter((item) => !item.hasAttribute('zen-empty-tab')) diff --git a/src/zen/tabs/ZenPinnedTabManager.mjs b/src/zen/tabs/ZenPinnedTabManager.mjs index 182fa63eb..db35a4d5a 100644 --- a/src/zen/tabs/ZenPinnedTabManager.mjs +++ b/src/zen/tabs/ZenPinnedTabManager.mjs @@ -703,56 +703,6 @@ class nsZenPinnedTabManager extends nsZenDOMOperatedFeature { : [separator]; } - animateSeparatorMove(movingTabs, dropElement, isPinned) { - let draggedTab = movingTabs[0]; - if (gBrowser.isTabGroupLabel(draggedTab) && draggedTab.group.isZenFolder) { - this._isGoingToPinnedTabs = true; - return; - } - if (draggedTab?.group?.hasAttribute('split-view-group')) { - draggedTab = draggedTab.group; - } - const itemsToCheck = this.dragShiftableItems; - let translate = movingTabs[isPinned ? movingTabs.length - 1 : 0].getBoundingClientRect().top; - if (isPinned) { - const rect = draggedTab.getBoundingClientRect(); - translate += rect.height; - } - const draggingTabHeight = movingTabs.reduce((acc, item) => { - return acc + window.windowUtils.getBoundsWithoutFlushing(item).height; - }, 0); - if (typeof this._topToNormalTabs === 'undefined') { - const rects = itemsToCheck.map((item) => window.windowUtils.getBoundsWithoutFlushing(item)); - this._topToNormalTabs = rects[0].top + rects.at(-1).height / (isPinned ? 2 : 4); - } - let topToNormalTabs = this._topToNormalTabs; - const isGoingToPinnedTabs = - translate < topToNormalTabs && gBrowser.pinnedTabCount - gBrowser._numZenEssentials > 0; - const multiplier = isGoingToPinnedTabs !== isPinned ? (isGoingToPinnedTabs ? 1 : -1) : 0; - this._isGoingToPinnedTabs = isGoingToPinnedTabs; - if (!dropElement) { - itemsToCheck.forEach((item) => { - item.style.transform = `translateY(${draggingTabHeight * multiplier}px)`; - }); - } - } - - getLastTabBound(lastBound, lastTab, isDraggingFolder = false) { - if (!lastTab.pinned || isDraggingFolder) { - return lastBound; - } - const shiftedItems = this.dragShiftableItems; - let totalHeight = shiftedItems.reduce((acc, item) => { - return acc + window.windowUtils.getBoundsWithoutFlushing(item).height; - }, 0); - if (shiftedItems.length === 1) { - // Means the new tab button is not at the top or not visible - const lastTabRect = window.windowUtils.getBoundsWithoutFlushing(lastTab); - totalHeight += lastTabRect.height; - } - return lastBound + totalHeight + 6; - } - get dragIndicator() { if (!this._dragIndicator) { this._dragIndicator = document.createElement('div');