diff --git a/src/browser/app/profile/features/split-view.inc b/src/browser/app/profile/features/split-view.inc index 95bd25af1d..3a679f82e5 100644 --- a/src/browser/app/profile/features/split-view.inc +++ b/src/browser/app/profile/features/split-view.inc @@ -3,5 +3,6 @@ # file, You can obtain one at http://mozilla.org/MPL/2.0/. pref('zen.splitView.enable-tab-drop', true); +pref('zen.splitView.enable-link-drop', true); pref('zen.splitView.min-resize-width', 7); pref('zen.splitView.rearrange-hover-size', 24); diff --git a/src/zen/split-view/ZenViewSplitter.mjs b/src/zen/split-view/ZenViewSplitter.mjs index fcf394b475..927e078665 100644 --- a/src/zen/split-view/ZenViewSplitter.mjs +++ b/src/zen/split-view/ZenViewSplitter.mjs @@ -63,6 +63,393 @@ class nsSplitNode extends nsSplitLeafNode { } } +class ZenSplitViewLinkDrop { + #zenViewSplitter; + _linkDropZone = null; + _lastSplitSide = 'right'; + + _svgIcon = new DOMParser().parseFromString( + ` + + + + + + `, + 'image/svg+xml' + ).documentElement; + _svgIconLeftFill = null; + _svgIconRightFill = null; + + constructor(zenViewSplitter) { + this.#zenViewSplitter = zenViewSplitter; + } + + init() { + const tabBox = document.getElementById('tabbrowser-tabbox'); + + tabBox.addEventListener('dragenter', this._handleLinkDragEnter.bind(this)); + tabBox.addEventListener('dragleave', this._handleLinkDragLeave.bind(this)); + tabBox.addEventListener('drop', this._handleLinkDragDrop.bind(this)); + tabBox.addEventListener('dragend', this._handleLinkDragEnd.bind(this)); + } + + _createLinkDropZone() { + const wrapper = document.createXULElement('box'); + wrapper.id = 'zen-drop-link-wrapper'; + + this._linkDropZone = document.createXULElement('box'); + this._linkDropZone.id = 'zen-drop-link-zone'; + + const content = document.createXULElement('vbox'); + content.setAttribute('align', 'center'); + content.setAttribute('pack', 'center'); + content.setAttribute('flex', '1'); + + this._svgIcon.id = 'zen-drop-link-icon'; + + this._svgIconLeftFill = this._svgIcon.querySelector('#left-fill'); + this._svgIconRightFill = this._svgIcon.querySelector('#right-fill'); + + content.appendChild(this._svgIcon); + this._updateIconForSide('center'); + + const text = document.createXULElement('description'); + text.setAttribute('data-l10n-id', 'zen-drop-link-zone-label'); + text.style.marginTop = '8px'; + + content.appendChild(text); + this._linkDropZone.appendChild(content); + + wrapper.appendChild(this._linkDropZone); + + this._linkDropZone.addEventListener('dragover', this._handleDragOver.bind(this)); + this._linkDropZone.addEventListener('dragleave', this._handleDragLeave.bind(this)); + this._linkDropZone.addEventListener('drop', this._handleDropForSplit.bind(this)); + + const tabBox = document.getElementById('tabbrowser-tabbox'); + tabBox.appendChild(wrapper); + + gZenUIManager.motion.animate(this._linkDropZone, { + opacity: [0, 1], + scale: [0.1, 1], + duration: 0.15, + ease: [0.16, 1, 0.3, 1], + }); + } + + _updateIconForSide(side) { + this._svgIcon.style.transform = 'rotate(0deg)'; + this._svgIconLeftFill.style.opacity = '1'; + this._svgIconRightFill.style.opacity = '1'; + + switch (side) { + case 'left': + this._svgIconRightFill.style.opacity = '0'; + break; + case 'right': + this._svgIconLeftFill.style.opacity = '0'; + break; + case 'top': + this._svgIcon.style.transform = 'rotate(90deg)'; + this._svgIconRightFill.style.opacity = '0'; + break; + case 'bottom': + this._svgIcon.style.transform = 'rotate(-90deg)'; + this._svgIconRightFill.style.opacity = '0'; + break; + } + } + + _handleDragOver(event) { + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = 'link'; + const side = this._calculateDropSide(event, this._linkDropZone); + this._linkDropZone.setAttribute('drop-side', side); + + this._updateIconForSide(side); + + if (!this._linkDropZone.hasAttribute('has-focus')) { + this._linkDropZone.setAttribute('has-focus', 'true'); + gZenUIManager.motion.animate(this._linkDropZone, { + scale: [1, 1.1], + duration: 0.15, + }); + } + } + + _handleDragLeave(event) { + event.stopPropagation(); + if (!this._linkDropZone.contains(event.relatedTarget)) { + this._updateIconForSide('center'); + gZenUIManager.motion + .animate(this._linkDropZone, { + scale: [1.1, 1], + duration: 0.15, + }) + .then(() => { + this._linkDropZone.removeAttribute('has-focus'); + this._linkDropZone.removeAttribute('drop-side'); + }); + } + } + + _removeLinkDropZone() { + if (!this._linkDropZone) return; + + const wrapper = this._linkDropZone.parentElement; + + gZenUIManager.motion + .animate(this._linkDropZone, { + opacity: [1, 0], + scale: [1, 0.1], + duration: 0.15, + ease: [0.16, 1, 0.3, 1], + }) + .then(() => { + wrapper.remove(); + this._linkDropZone = null; + this._svgIconLeftFill = null; + this._svgIconRightFill = null; + }); + } + + _validateURI(dataTransfer) { + let dt = dataTransfer; + + const URL_TYPES = ['text/uri-list', 'text/x-moz-url', 'text/plain']; + + let fixupFlags = + Ci.nsIURIFixup.FIXUP_FLAG_FIX_SCHEME_TYPOS | Ci.nsIURIFixup.FIXUP_FLAG_ALLOW_KEYWORD_LOOKUP; + + const matchedType = URL_TYPES.find((type) => { + const raw = dt.getData(type); + return typeof raw === 'string' && raw.trim().length > 0; + }); + + const uriString = dt.getData(matchedType).trim(); + + if (!uriString) { + return null; + } + + const info = Services.uriFixup.getFixupURIInfo(uriString, fixupFlags); + + if (!info || !info.fixedURI) { + return null; + } + + return info.fixedURI.spec; + } + + _handleLinkDragEnter(event) { + event.preventDefault(); + event.stopPropagation(); + + // If rearrangeViewEnabled - don't do anything + if (this.#zenViewSplitter.rearrangeViewEnabled) { + return; + } + + const shouldBeDisabled = !this.#zenViewSplitter.canOpenLinkInSplitView(); + if (shouldBeDisabled) return; + + // If _linkDropZone is already created, we don't want to do anything + if (this._linkDropZone) { + return; + } + + // If the data is not a valid URI, we don't want to do anything + if (!this._validateURI(event.dataTransfer)) { + return; + } + + this._createLinkDropZone(); + } + + _handleLinkDragLeave(event) { + if ( + event.target === document.documentElement || + (event.clientX <= 0 && event.clientY <= 0) || + event.clientX >= window.innerWidth || + event.clientY >= window.innerHeight + ) { + if (this._linkDropZone && !this._linkDropZone.contains(event.relatedTarget)) { + this._removeLinkDropZone(); + } + } + } + + _handleLinkDragDrop(event) { + if (!this._linkDropZone || !this._linkDropZone.contains(event.target)) { + this._removeLinkDropZone(); + } + } + + _handleLinkDragEnd(event) { + this._removeLinkDropZone(); + } + + _handleDropForSplit(event) { + let linkDropZone = this._linkDropZone; + event.preventDefault(); + event.stopPropagation(); + + const url = this._validateURI(event.dataTransfer); + + if (!url) { + this._removeLinkDropZone(); + return; + } + + const currentTab = gZenGlanceManager.getTabOrGlanceParent(gBrowser.selectedTab); + const newTab = this.#zenViewSplitter.openAndSwitchToTab(url, { inBackground: false }); + + if (!newTab) { + this._removeLinkDropZone(); + return; + } + + const linkDropSide = this._calculateDropSide(event, linkDropZone); + + if (linkDropSide === 'center') { + const rect = event.target.getBoundingClientRect(); + gZenGlanceManager.openGlance( + { + clientX: rect.left, + clientY: rect.top, + width: rect.width, + height: rect.height, + }, + newTab, + currentTab + ); + + this._removeLinkDropZone(); + return; + } + + this._dispatchSplitAction(currentTab, newTab, linkDropSide); + + this._removeLinkDropZone(); + } + + _calculateDropSide(event, linkDropZone) { + const rect = linkDropZone.getBoundingClientRect(); + const x = event.clientX - rect.left; + const y = event.clientY - rect.top; + const width = rect.width; + const height = rect.height; + + // Defines the size of the "active" zone near the edges (30%) + const EDGE_SIZE_RATIO = 0.3; + const hEdge = width * EDGE_SIZE_RATIO; + const vEdge = height * EDGE_SIZE_RATIO; + + const isInLeftEdge = x < hEdge; + const isInRightEdge = x > width - hEdge; + const isInTopEdge = y < vEdge; + const isInBottomEdge = y > height - vEdge; + + if (isInTopEdge) { + // If the cursor is in a top corner, determine which side it's proportionally "closer" to + // This comparison decides if it's a side drop or a top drop + if (isInLeftEdge && x / width < y / height) return 'left'; + if (isInRightEdge && (width - x) / width < y / height) return 'right'; + return 'top'; + } + if (isInBottomEdge) { + // Similar logic for the bottom corners + if (isInLeftEdge && x / width < (height - y) / height) return 'left'; + if (isInRightEdge && (width - x) / width < (height - y) / height) return 'right'; + return 'bottom'; + } + if (isInLeftEdge) { + return 'left'; + } + if (isInRightEdge) { + return 'right'; + } + + // If the cursor is not in any edge zone, it's considered the center + return 'center'; + } + + _dispatchSplitAction(currentTab, newTab, linkDropSide) { + const groupIndex = this.#zenViewSplitter._data.findIndex((group) => + group.tabs.includes(currentTab) + ); + + if (groupIndex > -1) { + this._addToExistingGroup(groupIndex, currentTab, newTab, linkDropSide); + } else { + this._createNewSplitGroup(currentTab, newTab, linkDropSide); + } + } + + _addToExistingGroup(groupIndex, currentTab, newTab, linkDropSide) { + const group = this.#zenViewSplitter._data[groupIndex]; + const splitViewGroup = this.#zenViewSplitter._getSplitViewGroup(group.tabs); + + if (splitViewGroup && newTab.group !== splitViewGroup) { + this.#zenViewSplitter._moveTabsToContainer([newTab], currentTab); + gBrowser.moveTabToGroup(newTab, splitViewGroup); + } + + if (!group.tabs.includes(newTab)) { + group.tabs.push(newTab); + + const targetNode = this.#zenViewSplitter.getSplitNodeFromTab(currentTab); + const parentNode = targetNode?.parent || group.layoutTree; + const isValidSide = ['left', 'right', 'top', 'bottom'].includes(linkDropSide); + + if (targetNode && isValidSide) { + this._lastSplitSide = linkDropSide; + + this.#zenViewSplitter.splitIntoNode( + targetNode, + new nsSplitLeafNode(newTab), + linkDropSide, + 0.5 + ); + + // Rebalance sizes + const newSize = 100 / parentNode.children.length; + parentNode.children.forEach((child) => { + child.sizeInParent = newSize; + }); + } + + this.#zenViewSplitter.activateSplitView(group, true); + } + } + + _createNewSplitGroup(currentTab, newTab, linkDropSide) { + const splitConfig = { + left: { tabs: [newTab, currentTab], gridType: 'vsep', initialIndex: 0 }, + right: { tabs: [currentTab, newTab], gridType: 'vsep', initialIndex: 1 }, + top: { tabs: [newTab, currentTab], gridType: 'hsep', initialIndex: 0 }, + bottom: { tabs: [currentTab, newTab], gridType: 'hsep', initialIndex: 1 }, + }; + + const defaultConfig = { + tabs: [currentTab, newTab], + gridType: 'vsep', + initialIndex: 1, + }; + + const { + tabs: tabsToSplit, + gridType, + initialIndex, + } = splitConfig[linkDropSide] || defaultConfig; + + this._lastSplitSide = linkDropSide; + this.#zenViewSplitter.splitTabs(tabsToSplit, gridType, initialIndex); + } +} + class nsZenViewSplitter extends ZenDOMOperatedFeature { currentView = -1; _data = []; @@ -79,6 +466,8 @@ class nsZenViewSplitter extends ZenDOMOperatedFeature { MAX_TABS = 4; + #ZenSplitViewLinkDrop; + init() { this.handleTabEvent = this._handleTabEvent.bind(this); @@ -123,6 +512,11 @@ class nsZenViewSplitter extends ZenDOMOperatedFeature { tabBox.addEventListener('dragover', this.onBrowserDragOverToSplit.bind(this)); this.onBrowserDragEndToSplit = this.onBrowserDragEndToSplit.bind(this); } + + if (Services.prefs.getBoolPref('zen.splitView.enable-link-drop')) { + this.#ZenSplitViewLinkDrop = new ZenSplitViewLinkDrop(this); + this.#ZenSplitViewLinkDrop.init(); + } } insertIntoContextMenu() { diff --git a/src/zen/split-view/zen-decks.css b/src/zen/split-view/zen-decks.css index 1d81fd62cd..9f916227dc 100644 --- a/src/zen/split-view/zen-decks.css +++ b/src/zen/split-view/zen-decks.css @@ -221,3 +221,100 @@ transition-delay: 0s; } } + +@keyframes zen-rotate-orbit { + 0% { + transform: translate(-50%, -50%) rotate(0deg) translateX(6px) rotate(0deg); + } + 100% { + transform: translate(-50%, -50%) rotate(360deg) translateX(6px) rotate(-360deg); + } +} + +#zen-drop-link-wrapper { + position: fixed; + top: 50%; + left: 50%; + width: 200px; + height: 260px; + z-index: 999; + animation: zen-rotate-orbit 3s linear infinite; +} + +#zen-drop-link-zone { + width: 100%; + height: 100%; + background: var(--zen-branding-bg); + border: 2px solid transparent; + border-radius: 18px; + box-shadow: rgba(0, 0, 0, 0.45) 0px 15px 35px; + + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + opacity: 0; + pointer-events: auto; + + transition: + background-color 0.15s ease, + border-color 0.15s ease; + will-change: transform; + + &::before { + content: ''; + position: absolute; + top: 6px; + left: 6px; + right: 6px; + bottom: 6px; + + border-radius: 12px; + border: 2px dashed color-mix(in srgb, var(--toolbox-textcolor), transparent 20%); + transition: border-color 0.15s ease; + pointer-events: none; + } + + &[has-focus='true'] { + border-color: var(--zen-colors-border); + background: color-mix(in srgb, var(--zen-branding-bg), var(--zen-primary-color) 30%); + &::before { + border: 2px dashed color-mix(in srgb, var(--zen-primary-color), transparent 20%); + } + & description { + color: color-mix(in srgb, var(--toolbox-textcolor), var(--zen-primary-color) 50%); + } + } + + &[drop-side='left'] { + border-left-color: var(--zen-primary-color); + } + + &[drop-side='right'] { + border-right-color: var(--zen-primary-color); + } + + &[drop-side='top'] { + border-top-color: var(--zen-primary-color); + } + + &[drop-side='bottom'] { + border-bottom-color: var(--zen-primary-color); + } + + #zen-drop-link-icon { + width: 48px; + height: 48px; + } + + & description { + font-size: 14px; + line-height: 1.4; + text-align: center; + color: color-mix(in srgb, var(--toolbox-textcolor), transparent 20%); + font-weight: 600; + letter-spacing: 0.2px; + user-select: none; + } +} diff --git a/src/zen/tests/moz.build b/src/zen/tests/moz.build index a68349d9af..2702deff1e 100644 --- a/src/zen/tests/moz.build +++ b/src/zen/tests/moz.build @@ -7,6 +7,7 @@ BROWSER_CHROME_MANIFESTS += [ "container_essentials/browser.toml", "glance/browser.toml", "pinned/browser.toml", + "split_view/browser.toml", "urlbar/browser.toml", "welcome/browser.toml", "workspaces/browser.toml", diff --git a/src/zen/tests/split_view/browser.toml b/src/zen/tests/split_view/browser.toml new file mode 100644 index 0000000000..ef5b0b20ff --- /dev/null +++ b/src/zen/tests/split_view/browser.toml @@ -0,0 +1,11 @@ +[DEFAULT] +prefs = ["zen.splitView.enable-link-drop=true"] + +support-files = [ + "head.js", +] + +["browser_link_drop_split_right.js"] +["browser_link_drop_split_top.js"] +["browser_link_drop_center_opens_glance.js"] +["browser_link_drop_add_to_existing_split.js"] diff --git a/src/zen/tests/split_view/browser_link_drop_add_to_existing_split.js b/src/zen/tests/split_view/browser_link_drop_add_to_existing_split.js new file mode 100644 index 0000000000..e59185b35e --- /dev/null +++ b/src/zen/tests/split_view/browser_link_drop_add_to_existing_split.js @@ -0,0 +1,29 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +'use strict'; + +add_task(async function test_link_drop_add_to_existing_split() { + info('Starting test: Adding a link to an existing split view.'); + const initialTab = gBrowser.selectedTab; + + await simulateLinkDragAndDrop('right'); // Create an initial split + + await TestUtils.waitForCondition( + () => gZenViewSplitter.splitViewActive && getNonEmptyTabCount() === 2, + 'Wait for initial split view to become active' + ); + + // Drop another link to add to the existing split + await simulateLinkDragAndDrop('left'); + + await TestUtils.waitForCondition( + () => getNonEmptyTabCount() === 3, + 'Wait for the third tab to be added' + ); + + const groupData = gZenViewSplitter._data.find((g) => g.tabs.includes(initialTab)); + is(groupData.tabs.length, 3, 'The group should now contain three tabs.'); + + await cleanupSplitView(); +}); diff --git a/src/zen/tests/split_view/browser_link_drop_center_opens_glance.js b/src/zen/tests/split_view/browser_link_drop_center_opens_glance.js new file mode 100644 index 0000000000..77573a4079 --- /dev/null +++ b/src/zen/tests/split_view/browser_link_drop_center_opens_glance.js @@ -0,0 +1,23 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +'use strict'; + +add_task(async function test_link_drop_center_opens_glance() { + info('Starting test: Dropping a link in the center opens a glance view.'); + is(getNonEmptyTabCount(), 1, 'Should start with one tab.'); + ok(!gZenViewSplitter.splitViewActive, 'Split view should not be active initially.'); + + await simulateLinkDragAndDrop('center'); + + const glanceTab = await TestUtils.waitForCondition( + () => gBrowser.tabs.find((t) => t.hasAttribute('zen-glance-tab')), + 'Wait for glance tab to appear' + ); + + ok(glanceTab, 'Glance tab should be created.'); + is(getNonEmptyTabCount(), 2, 'Should have two tabs after opening glance.'); + ok(!gZenViewSplitter.splitViewActive, 'Split view should not be activated for a center drop.'); + + await cleanupSplitView(); +}); diff --git a/src/zen/tests/split_view/browser_link_drop_split_right.js b/src/zen/tests/split_view/browser_link_drop_split_right.js new file mode 100644 index 0000000000..c44913bc63 --- /dev/null +++ b/src/zen/tests/split_view/browser_link_drop_split_right.js @@ -0,0 +1,25 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +'use strict'; + +add_task(async function test_link_drop_split_right() { + info('Starting test: Splitting a new view by dropping a link on the right.'); + const initialTab = gBrowser.selectedTab; + is(getNonEmptyTabCount(), 1, 'Should start with one tab.'); + ok(!gZenViewSplitter.splitViewActive, 'Split view should not be active initially.'); + + await simulateLinkDragAndDrop('right'); + + await TestUtils.waitForCondition( + () => gZenViewSplitter.splitViewActive, + 'Wait for split view to become active' + ); + + is(getNonEmptyTabCount(), 2, 'Should have two tabs after split.'); + const groupData = gZenViewSplitter._data.find((g) => g.tabs.includes(initialTab)); + ok(groupData, 'A split view group should be created.'); + is(groupData.gridType, 'vsep', 'Grid type should be vertical split.'); + + await cleanupSplitView(); +}); diff --git a/src/zen/tests/split_view/browser_link_drop_split_top.js b/src/zen/tests/split_view/browser_link_drop_split_top.js new file mode 100644 index 0000000000..a565dd2eb9 --- /dev/null +++ b/src/zen/tests/split_view/browser_link_drop_split_top.js @@ -0,0 +1,24 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +'use strict'; + +add_task(async function test_link_drop_split_top() { + info('Starting test: Splitting a new view by dropping a link on the top.'); + const initialTab = gBrowser.selectedTab; + is(getNonEmptyTabCount(), 1, 'Should start with one tab.'); + + await simulateLinkDragAndDrop('top'); + + await TestUtils.waitForCondition( + () => gZenViewSplitter.splitViewActive, + 'Wait for split view to become active' + ); + + is(getNonEmptyTabCount(), 2, 'Should have two tabs after split.'); + const groupData = gZenViewSplitter._data.find((g) => g.tabs.includes(initialTab)); + ok(groupData, 'A split view group should be created.'); + is(groupData.gridType, 'hsep', 'Grid type should be horizontal split.'); + + await cleanupSplitView(); +}); diff --git a/src/zen/tests/split_view/head.js b/src/zen/tests/split_view/head.js new file mode 100644 index 0000000000..99ebfe11c8 --- /dev/null +++ b/src/zen/tests/split_view/head.js @@ -0,0 +1,113 @@ +/* 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/. */ + +/** + * Returns the count of tabs that do not have the 'zen-empty-tab' attribute. + * + * @returns {number} The number of non-empty tabs. + */ +function getNonEmptyTabCount() { + return gBrowser.tabs.filter((tab) => !tab.hasAttribute('zen-empty-tab')).length; +} + +/** + * Simulates dragging and dropping a link to test split view. + * + * @param {string} side The side to drop on ('left', 'right', 'top', 'bottom', 'center'). + * @param {string} [url='https://example.com/'] The URL to be dragged. + */ +async function simulateLinkDragAndDrop(side, url = 'https://example.com/') { + const tabBox = document.getElementById('tabbrowser-tabbox'); + const dataTransfer = new DataTransfer(); + dataTransfer.setData('text/uri-list', url); + dataTransfer.setData('text/plain', url); + + // 1. Enter the tab area to trigger drop zone creation. + tabBox.dispatchEvent(new DragEvent('dragenter', { bubbles: true, view: window, dataTransfer })); + + const dropZone = await TestUtils.waitForCondition( + () => document.getElementById('zen-drop-link-zone'), + 'Wait for link drop zone to appear' + ); + + // 2. Calculate coordinates for the drop. + const rect = dropZone.getBoundingClientRect(); + const edgeRatio = 0.2; // Should be less than EDGE_SIZE_RATIO in the source + const position = { + left: rect.left + rect.width * edgeRatio, + right: rect.right - rect.width * edgeRatio, + top: rect.top + rect.height * edgeRatio, + bottom: rect.bottom - rect.height * edgeRatio, + hCenter: rect.left + rect.width / 2, + vCenter: rect.top + rect.height / 2, + }; + + const coords = { + left: { clientX: position.left, clientY: position.vCenter }, + right: { clientX: position.right, clientY: position.vCenter }, + top: { clientX: position.hCenter, clientY: position.top }, + bottom: { clientX: position.hCenter, clientY: position.bottom }, + center: { clientX: position.hCenter, clientY: position.vCenter }, + }; + + // 3. Drag over the drop zone to set the side. + dropZone.dispatchEvent( + new DragEvent('dragover', { bubbles: true, view: window, dataTransfer, ...coords[side] }) + ); + await TestUtils.waitForCondition( + () => dropZone.getAttribute('drop-side') === side, + `Wait for drop-side to be '${side}'` + ); + + // 4. Drop. + dropZone.dispatchEvent( + new DragEvent('drop', { bubbles: true, view: window, dataTransfer, ...coords[side] }) + ); + + // 5. End drag to clean up. + tabBox.dispatchEvent(new DragEvent('dragend', { bubbles: true, view: window, dataTransfer })); +} + +/** + * Cleans up any split or glance views after a test and ensures only one clean tab remains. + */ +async function cleanupSplitView() { + // 1. Ensure all split views are closed. + if (gZenViewSplitter.splitViewActive) { + gZenViewSplitter.unsplitCurrentView(); + await TestUtils.waitForCondition( + () => !gZenViewSplitter.splitViewActive, + 'Wait for split view to become inactive during cleanup' + ); + } + + // 2. Close any active glance view. + const glanceTab = gBrowser.tabs.find((t) => t.hasAttribute('zen-glance-tab')); + if (glanceTab) { + await BrowserTestUtils.removeTab(glanceTab); + await TestUtils.waitForCondition( + () => gBrowser.tabs.find((t) => !t.hasAttribute('zen-glance-tab')), // Check if tab is removed from DOM + 'Wait for glance tab to be removed during cleanup' + ); + } + + // 3. Create a new, clean tab that will be the only tab remaining. + let newTab = await BrowserTestUtils.openNewForegroundTab(window.gBrowser, 'about:blank', true); + gBrowser.selectedTab = newTab; + + // 4. Remove all other tabs, excluding the newly created one. + // Convert gBrowser.tabs to an array to avoid issues with live collections changing during iteration. + const tabsToRemove = Array.from(gBrowser.tabs).filter((tab) => tab !== newTab); + + for (const tab of tabsToRemove) { + // BrowserTestUtils.removeTab correctly waits for the tab to be removed. + await BrowserTestUtils.removeTab(tab); + } + + // Final check: ensure exactly one non-empty tab remains and it is the new tab. + await TestUtils.waitForCondition( + () => getNonEmptyTabCount() === 1 && gBrowser.selectedTab === newTab, + 'Wait for only one non-empty tab to remain after cleanup, and it should be the new tab.' + ); +}