tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

commit 0b8b68e33fd82133ccc84174181a59330a6e2135
parent 10601476c58592359e676e95c5776e927ac10a8c
Author: Sarah Clements <sclements@mozilla.com>
Date:   Wed, 17 Dec 2025 14:26:20 +0000

Bug 1986948 - Implement drag n'drop for splitview containers r=tabbrowser-reviewers,nsharpley

* Update drag n'drop modules to allow for splitview containers to be dragged within
the tabstrip, including within tab groups and to be dragged to another window
* Add browser_drag_splitview test module

Differential Revision: https://phabricator.services.mozilla.com/D270178

Diffstat:
Mbrowser/base/content/browser-init.js | 7+++++++
Mbrowser/components/tabbrowser/content/drag-and-drop.js | 22++++++++++++++++++----
Mbrowser/components/tabbrowser/content/tab-stacking.js | 31++++++++++++++++++++++++-------
Mbrowser/components/tabbrowser/content/tabbrowser.js | 83++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mbrowser/components/tabbrowser/content/tabgroup.js | 9+++++++++
Mbrowser/components/tabbrowser/content/tabs.js | 11+++++------
Mbrowser/components/tabbrowser/content/tabsplitview.js | 4++++
Mbrowser/components/tabbrowser/test/browser/dragdrop/browser.toml | 2++
Abrowser/components/tabbrowser/test/browser/dragdrop/browser_drag_splitview.js | 138+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbrowser/components/tabbrowser/test/browser/dragdrop/browser_drag_to_pin.js | 19++-----------------
Mbrowser/components/tabbrowser/test/browser/dragdrop/head.js | 22++++++++++++++++++++++
Mbrowser/themes/shared/tabbrowser/tabs.css | 32+++++++++++++++++---------------
12 files changed, 316 insertions(+), 64 deletions(-)

diff --git a/browser/base/content/browser-init.js b/browser/base/content/browser-init.js @@ -301,6 +301,13 @@ var gBrowserInit = { gBrowser.adoptTabGroup(tabToAdopt, { tabIndex: 0, selectTab: true }); gBrowser.removeTab(tempBlankTab); Glean.tabgroup.groupInteractions.move_window.add(1); + } else if (gBrowser.isSplitViewWrapper(tabToAdopt)) { + let tempBlankTab = gBrowser.selectedTab; + gBrowser.adoptSplitView(tabToAdopt, { + elementIndex: 0, + selectTab: true, + }); + gBrowser.removeTab(tempBlankTab); } else { if (tabToAdopt.group) { Glean.tabgroup.tabInteractions.remove_new_window.add(); diff --git a/browser/components/tabbrowser/content/drag-and-drop.js b/browser/components/tabbrowser/content/drag-and-drop.js @@ -75,6 +75,9 @@ if (!tab) { return; } + if (tab.splitview) { + tab = tab.splitview; + } this._tabbrowserTabs.previewPanel?.deactivate(null, { force: true }); this.startTabDrag(event, tab); @@ -379,6 +382,7 @@ !shouldCreateGroupOnDrop && !shouldDropIntoCollapsedTabGroup && !isTabGroupLabel(draggedTab) && + !isSplitViewWrapper(draggedTab) && !shouldPin && !shouldUnpin; if (this._isContainerVerticalPinnedGrid(draggedTab)) { @@ -501,6 +505,10 @@ gBrowser.adoptTabGroup(draggedTab.group, { elementIndex: this._getDropIndex(event), }); + } else if (isSplitViewWrapper(draggedTab)) { + gBrowser.adoptSplitView(draggedTab, { + elementIndex: this._getDropIndex(event), + }); } else if (draggedTab) { // Move the tabs into this window. To avoid multiple tab-switches in // the original window, the selected tab should be adopted last. @@ -811,7 +819,7 @@ } /** - * Returns the tab or tab group label where an event happened, or null if + * Returns the tab, tab group label or split view wrapper where an event happened, * it didn't occur on a tab or tab group label. * * @param {Event} event @@ -825,7 +833,11 @@ _getDragTarget(event, { ignoreSides = false } = {}) { let { target } = event; while (target) { - if (isTab(target) || isTabGroupLabel(target)) { + if ( + isTab(target) || + isTabGroupLabel(target) || + isSplitViewWrapper(target) + ) { break; } target = target.parentNode; @@ -984,7 +996,7 @@ } let dataTransferOrderedTabs; - if (fromTabList || isTabGroupLabel(tab)) { + if (fromTabList || isTabGroupLabel(tab) || isSplitViewWrapper(tab)) { // Dragging a group label or an item in the all tabs menu doesn't // change the currently selected tabs, and it's not possible to select // multiple tabs from the list, thus handle only the dragged tab in @@ -2524,7 +2536,9 @@ if (isMovingTab) { let sourceNode = dt.mozGetDataAt(TAB_DROP_TYPE, 0); if ( - (isTab(sourceNode) || isTabGroupLabel(sourceNode)) && + (isTab(sourceNode) || + isTabGroupLabel(sourceNode) || + isSplitViewWrapper(sourceNode)) && sourceNode.ownerGlobal.isChromeWindow && sourceNode.ownerDocument.documentElement.getAttribute("windowtype") == "navigator:browser" diff --git a/browser/components/tabbrowser/content/tab-stacking.js b/browser/components/tabbrowser/content/tab-stacking.js @@ -202,6 +202,7 @@ !shouldCreateGroupOnDrop && !shouldDropIntoCollapsedTabGroup && !isTabGroupLabel(draggedTab) && + !isSplitViewWrapper(draggedTab) && !shouldPin && !shouldUnpin; if (this._isContainerVerticalPinnedGrid(draggedTab)) { @@ -324,6 +325,10 @@ gBrowser.adoptTabGroup(draggedTab.group, { elementIndex: this._getDropIndex(event), }); + } else if (isSplitViewWrapper(draggedTab)) { + gBrowser.adoptSplitView(draggedTab, { + elementIndex: this._getDropIndex(event), + }); } else if (draggedTab) { // Move the tabs into this window. To avoid multiple tab-switches in // the original window, the selected tab should be adopted last. @@ -726,7 +731,7 @@ }); } - // Use .tab-group-label-container or .tabbrowser-tab for size/position + // Use .tab-group-label-container, tab-split-view-wrapper or .tabbrowser-tab for size/position // calculations. let rect = window.windowUtils.getBoundsWithoutFlushing(tabStripItemElement); @@ -1242,9 +1247,8 @@ dropElement?.currentIndex ?? dropElement.elementIndex; } else { dropBefore = false; - let lastVisibleTabInGroup = overlappedGroup.tabs.findLast( - tab => tab.visible - ); + let lastVisibleTabInGroup = + overlappedGroup.tabsAndSplitViews.findLast(ele => ele.visible); newDropElementIndex = (lastVisibleTabInGroup?.currentIndex ?? lastVisibleTabInGroup.elementIndex) + 1; @@ -1573,9 +1577,11 @@ let pinnedDropIndicator = draggedTabDocument.getElementById( "pinned-drop-indicator" ); + let draggedTabContainer = + draggedTabDocument.ownerGlobal.gBrowser.tabContainer; pinnedDropIndicator.removeAttribute("visible"); pinnedDropIndicator.removeAttribute("interactive"); - draggedTabDocument.ownerGlobal.gBrowser.tabContainer.style.maxWidth = ""; + draggedTabContainer.style.maxWidth = ""; let allTabs = draggedTabDocument.getElementsByClassName("tabbrowser-tab"); for (let tab of allTabs) { tab.style.width = ""; @@ -1598,7 +1604,7 @@ label.style.pointerEvents = ""; label.removeAttribute("dragtarget"); } - for (let label of draggedTabDocument.getElementsByClassName( + for (let label of draggedTabContainer.getElementsByClassName( "tab-group-label" )) { delete label.currentIndex; @@ -1627,12 +1633,23 @@ ); arrowScrollbox.scrollbox.style.height = ""; arrowScrollbox.scrollbox.style.width = ""; - for (let groupLabel of draggedTabDocument.getElementsByClassName( + for (let groupLabel of draggedTabContainer.getElementsByClassName( "tab-group-label-container" )) { groupLabel.style.left = ""; groupLabel.style.top = ""; } + for (let splitviewWrapper of draggedTabContainer.getElementsByTagName( + "tab-split-view-wrapper" + )) { + splitviewWrapper.style.width = ""; + splitviewWrapper.style.maxWidth = ""; + splitviewWrapper.style.height = ""; + splitviewWrapper.style.left = ""; + splitviewWrapper.style.top = ""; + splitviewWrapper.style.pointerEvents = ""; + splitviewWrapper.removeAttribute("dragtarget"); + } } }; } diff --git a/browser/components/tabbrowser/content/tabbrowser.js b/browser/components/tabbrowser/content/tabbrowser.js @@ -3207,6 +3207,9 @@ if (this.isTabGroupLabel(element)) { element = element.group.tabs[0]; } + if (this.isSplitViewWrapper(element)) { + element = element.tabs[0]; + } return element._tPos; } @@ -3605,6 +3608,8 @@ let oldSelectedTab = selectTab && group.ownerGlobal.gBrowser.selectedTab; let newTabs = []; + let adoptedTab; + let splitview; // bug1969925 adopting a tab group will cause the window to close if it // is the only thing on the tab strip @@ -3613,24 +3618,38 @@ t => t.group == group ); - for (let tab of group.tabs) { - if (noOtherTabsInWindow) { + // We dispatch this event in a separate for loop because the tab extension API + // expects event.detail to be a tab. + if (noOtherTabsInWindow) { + for (let element of group.tabs) { group.dispatchEvent( new CustomEvent("TabUngrouped", { bubbles: true, - detail: tab, + detail: element, }) ); } - let adoptedTab = this.adoptTab(tab, { - elementIndex, - tabIndex, - selectTab: tab === oldSelectedTab, - }); - newTabs.push(adoptedTab); - // Put next tab after current one. - elementIndex = undefined; - tabIndex = adoptedTab._tPos + 1; + } + + for (let element of group.tabsAndSplitViews) { + if (element.tagName == "tab-split-view-wrapper") { + splitview = this.adoptSplitView(element, { + elementIndex, + tabIndex, + }); + newTabs.push(splitview); + tabIndex = splitview.tabs[0]._tPos + splitview.tabs.length; + } else { + adoptedTab = this.adoptTab(element, { + elementIndex, + tabIndex, + selectTab: element === oldSelectedTab, + }); + newTabs.push(adoptedTab); + // Put next tab after current one. + elementIndex = undefined; + tabIndex = adoptedTab._tPos + 1; + } } return this.addTabGroup(newTabs, { @@ -3643,6 +3662,39 @@ } /** + * @param {MozSplitViewWrapper} container + * @param {object} [options] + * @param {number} [options.elementIndex] + * @param {number} [options.tabIndex] + * @param {boolean} [options.selectTab] + * @returns {MozSplitViewWrapper} + */ + adoptSplitView(container, { elementIndex, tabIndex } = {}) { + if (container.ownerDocument == document) { + return container; + } + + let newTabs = []; + + if (!tabIndex) { + tabIndex = this.#elementIndexToTabIndex(elementIndex); + } + + for (let tab of container.tabs) { + let adoptedTab = this.adoptTab(tab, { + tabIndex, + }); + newTabs.push(adoptedTab); + tabIndex = adoptedTab._tPos + 1; + } + + return this.addTabSplitView(newTabs, { + id: container.splitViewId, + insertBefore: newTabs[0], + }); + } + + /** * Get all open tab groups from all windows. Does not include saved groups. * * @param {object} [options] @@ -6817,6 +6869,9 @@ if (tab.group) { state.tabGroupId = tab.group.id; } + if (tab.splitview) { + state.splitViewId = tab.splitview.splitViewId; + } return state; } @@ -6862,9 +6917,7 @@ let tabs; if (this.isTab(element)) { tabs = [element]; - } else if (this.isTabGroup(element)) { - tabs = element.tabs; - } else if (this.isSplitViewWrapper(element)) { + } else if (this.isTabGroup(element) || this.isSplitViewWrapper(element)) { tabs = element.tabs; } else { throw new Error( diff --git a/browser/components/tabbrowser/content/tabgroup.js b/browser/components/tabbrowser/content/tabgroup.js @@ -476,6 +476,15 @@ } /** + * @returns {MozTabbrowserTab|MozTabSplitViewWrapper[]} + */ + get tabsAndSplitViews() { + return Array.from(this.children).filter( + node => node.matches("tab") || node.tagName == "tab-split-view-wrapper" + ); + } + + /** * @param {MozTabbrowserTab} tab * @returns {boolean} */ diff --git a/browser/components/tabbrowser/content/tabs.js b/browser/components/tabbrowser/content/tabs.js @@ -1003,14 +1003,13 @@ child.labelElement.elementIndex = elementIndex++; dragAndDropElements.push(child.labelElement); - let visibleChildren = Array.from(child.children).filter( - ele => ele.visible || ele.tagName == "tab-split-view-wrapper" + let tabsAndSplitViews = child.tabsAndSplitViews.filter( + node => node.visible ); - - visibleChildren.forEach(tab => { - tab.elementIndex = elementIndex++; + tabsAndSplitViews.forEach(ele => { + ele.elementIndex = elementIndex++; }); - dragAndDropElements.push(...visibleChildren); + dragAndDropElements.push(...tabsAndSplitViews); } else { child.elementIndex = elementIndex++; dragAndDropElements.push(child); diff --git a/browser/components/tabbrowser/content/tabsplitview.js b/browser/components/tabbrowser/content/tabsplitview.js @@ -145,6 +145,10 @@ return Array.from(this.children).filter(node => node.matches("tab")); } + get visible() { + return this.tabs.every(tab => tab.visible); + } + /** * Get the list of tab panels from this split view. * diff --git a/browser/components/tabbrowser/test/browser/dragdrop/browser.toml b/browser/components/tabbrowser/test/browser/dragdrop/browser.toml @@ -3,6 +3,8 @@ support-files = [ "head.js" ] +["browser_drag_splitview.js"] + ["browser_drag_to_pin.js"] ["browser_move_unpinned_not_overflowing.js"] diff --git a/browser/components/tabbrowser/test/browser/dragdrop/browser_drag_splitview.js b/browser/components/tabbrowser/test/browser/dragdrop/browser_drag_splitview.js @@ -0,0 +1,138 @@ +/* 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"; + +let currentReduceMotionOverride; + +add_setup(() => { + currentReduceMotionOverride = gReduceMotionOverride; + // Disable tab animations + gReduceMotionOverride = true; +}); + +add_task(async function test_drag_splitview_tab() { + let [tab1, tab2, tab3] = await Promise.all( + Array.from({ length: 3 }).map((_, index) => + addTab(`data:text/plain,tab${index + 1}`) + ) + ); + + const startingTab = gBrowser.tabs[0]; + let splitview = gBrowser.addTabSplitView([tab2, tab3]); + Assert.equal(splitview.tabs.length, 2, "Split view has 2 tabs"); + + Assert.deepEqual( + gBrowser.tabs, + [startingTab, tab1, tab2, tab3], + "confirm tabs' starting order" + ); + try { + info("Drag a splitview tab"); + await customDragAndDrop(tab3, tab1, null, waitForTabMove(tab3)); + + Assert.deepEqual( + gBrowser.tabs, + [startingTab, tab2, tab3, tab1], + "Confirm that tab3 and tab2 in a splitview move together before tab1" + ); + + info("Drag a tab in between a splitview"); + await customDragAndDrop(tab1, tab2, null, waitForTabMove(tab1)); + + Assert.deepEqual( + gBrowser.tabs, + [startingTab, tab1, tab2, tab3], + "Confirm that tab1 cannot be dragged in between splitview tabs" + ); + + let group = gBrowser.addTabGroup([tab1, splitview]); + Assert.equal(group.tabs.length, 3, "group has 3 tabs"); + + Assert.deepEqual( + gBrowser.tabs, + [startingTab, tab1, tab2, tab3], + "Confirm that splitview tabs can be reordered within a tab group" + ); + // ensure we drag before tab 1, not after + let event = getDragEvent(); + info("Drag splitview tabs within a tab group"); + await customDragAndDrop(tab2, tab1, null, null, event); + + Assert.deepEqual( + gBrowser.tabs, + [startingTab, tab2, tab3, tab1], + "Confirm that splitview tabs can be reordered within a tab group" + ); + } finally { + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); + BrowserTestUtils.removeTab(tab3); + } +}); + +add_task(async function test_dragging_splitview_second_window() { + let win2 = await BrowserTestUtils.openNewBrowserWindow(); + let [tab1, tab2] = await Promise.all( + Array.from({ length: 2 }).map((_, index) => + addTab(`data:text/plain,tab${index + 1}`) + ) + ); + + let splitview = window.gBrowser.addTabSplitView([tab1, tab2]); + Assert.equal(splitview.tabs.length, 2, "Split view has 2 tabs"); + + let secondWindowTab1 = win2.gBrowser.tabs[0]; + let secondWindowTab2 = BrowserTestUtils.addTab( + win2.gBrowser, + "data:text/plain,tab4" + ); + + is(win2.gBrowser.tabs.length, 2, "win2 contains 2 tabs"); + + let awaitCloseEvent = BrowserTestUtils.waitForEvent(tab1, "TabClose"); + let awaitOpenEvent = BrowserTestUtils.waitForEvent(win2, "TabOpen"); + + let effect = EventUtils.synthesizeDrop( + tab1, + secondWindowTab1, + [[{ type: TAB_DROP_TYPE, data: tab1 }]], + null, + window, + win2 + ); + is(effect, "move", "Tabs should be moved from win1 to win2."); + + let closeEvent = await awaitCloseEvent; + let openEvent = await awaitOpenEvent; + + is(openEvent.detail.adoptedTab, tab1, "New tab adopted old tab"); + + is( + closeEvent.detail.adoptedBy, + openEvent.target, + "Old tab adopted by new tab" + ); + + is( + win2.gBrowser.tabs.length, + 4, + "win2 contains 2 new tabs, for a total of 4" + ); + + let [secondWindowTab3, secondWindowTab4] = win2.gBrowser.tabs.slice(1, 3); + + Assert.deepEqual( + win2.gBrowser.tabs, + [secondWindowTab1, secondWindowTab3, secondWindowTab4, secondWindowTab2], + "Two new tabs were inserted in the correct position in the second window" + ); + + ok( + secondWindowTab3.splitview && secondWindowTab4.splitview, + "Two new tabs in second window are splitview tabs" + ); + + await BrowserTestUtils.closeWindow(win2); +}); diff --git a/browser/components/tabbrowser/test/browser/dragdrop/browser_drag_to_pin.js b/browser/components/tabbrowser/test/browser/dragdrop/browser_drag_to_pin.js @@ -25,21 +25,6 @@ registerCleanupFunction(() => { CustomizableUI.reset(); }); -function getDragEvent(win, isVertical = false) { - let tabContainer = win.document.getElementById("tabbrowser-tabs"); - let tabContainerRect = win.windowUtils.getBoundsWithoutFlushing(tabContainer); - // Drag to the starting edge of the tab container - return { - clientX: isVertical - ? tabContainerRect.x + tabContainerRect.width / 2 - : tabContainerRect.x + 1, - clientY: isVertical - ? tabContainerRect.y + 1 - : tabContainerRect.y + tabContainerRect.height / 2, - dropEffect: "move", - }; -} - async function pinIndicatorDragCond(pinnedDropIndicator) { info("Wait for interaction cue"); await BrowserTestUtils.waitForMutationCondition( @@ -61,7 +46,7 @@ add_task(async function test_pin_to_pinned_drop_indicator_horizontal() { let unpinnedTabsContainer = document.getElementById( "tabbrowser-arrowscrollbox" ); - let dragEvent = getDragEvent(window); + let dragEvent = getDragEvent(); info("Drag to pin to the interaction cue"); await customDragAndDrop( @@ -155,7 +140,7 @@ add_task(async function test_pin_to_promo_card_vertical() { let initialTab = gBrowser.tabs[0]; let tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); let promoCard = document.getElementById("drag-to-pin-promo-card"); - let dragEvent = getDragEvent(window, true); + let dragEvent = getDragEvent(true); async function promoPinDragCond() { info("Wait for promo card"); diff --git a/browser/components/tabbrowser/test/browser/dragdrop/head.js b/browser/components/tabbrowser/test/browser/dragdrop/head.js @@ -141,3 +141,25 @@ function waitForTabMove(tab) { "Tab did not change position after a drop" ); } + +/** + * @param {boolean} Optional + * Whether to apply to vertical or horizontal tabs + * @returns {object} + * Returns properties used to emulate a drag event. + */ +function getDragEvent(isVertical = false) { + let tabContainerRect = window.windowUtils.getBoundsWithoutFlushing( + gBrowser.tabContainer + ); + // Drag to the starting edge of the tab container + return { + clientX: isVertical + ? tabContainerRect.x + tabContainerRect.width / 2 + : tabContainerRect.x + 1, + clientY: isVertical + ? tabContainerRect.y + 1 + : tabContainerRect.y + tabContainerRect.height / 2, + dropEffect: "move", + }; +} diff --git a/browser/themes/shared/tabbrowser/tabs.css b/browser/themes/shared/tabbrowser/tabs.css @@ -188,6 +188,22 @@ position: relative; } +.tabbrowser-tab[dragtarget], +tab-split-view-wrapper[dragtarget] { + /* Margin needs to be at least 4px to not clip multiselect stacking effect */ + overflow-clip-margin: 4px; + 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; + } + } +} + .tabbrowser-tab { --tab-label-mask-size: 1em; @@ -233,21 +249,6 @@ } } - &[dragtarget] { - /* Margin needs to be at least 4px to not clip multiselect stacking effect */ - overflow-clip-margin: 4px; - 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; - } - } - } - #tabbrowser-tabs[movingtab] & { position: relative; @@ -1323,6 +1324,7 @@ tab-group > tab-split-view-wrapper { margin-inline: var(--space-small); padding: var(--space-xsmall); border-radius: var(--tab-border-radius); + position: relative; /* prevents splitview wrappers from shifting during other drag events*/ &[hasactivetab] { background-color: var(--tab-hover-background-color);