From 049c3c6f54aee683e4eaa31519acd8b52096a9c8 Mon Sep 17 00:00:00 2001 From: Andrey Bochkarev <50177704+octaviusz@users.noreply.github.com> Date: Sun, 8 Feb 2026 01:59:28 +0300 Subject: [PATCH] feat: Implement vertical dnd tab splitting, p=#12289 --- src/zen/split-view/ZenViewSplitter.mjs | 392 +++++++++++++++---------- src/zen/split-view/zen-decks.css | 21 +- 2 files changed, 257 insertions(+), 156 deletions(-) diff --git a/src/zen/split-view/ZenViewSplitter.mjs b/src/zen/split-view/ZenViewSplitter.mjs index ad968a02f..d4234d46d 100644 --- a/src/zen/split-view/ZenViewSplitter.mjs +++ b/src/zen/split-view/ZenViewSplitter.mjs @@ -208,6 +208,10 @@ class nsZenViewSplitter extends nsZenDOMOperatedFeature { if (typeof groupIndex === "undefined") { groupIndex = this._data.findIndex((group) => group.tabs.includes(tab)); } + // If groupIndex === -1, so `this._data.findIndex` couldn't find the split group + if (groupIndex < 0) { + return; + } const group = this._data[groupIndex]; const tabIndex = group.tabs.indexOf(tab); group.tabs.splice(tabIndex, 1); @@ -259,6 +263,33 @@ class nsZenViewSplitter extends nsZenDOMOperatedFeature { return element; } + _calculateDropSide(event, panelsRect) { + const { width, height } = panelsRect; + const { clientX, clientY } = event; + // TODO(octaviusz): Maybe we should add this as preference + // `zen.splitView.tab-drop-treshold` + const quarterWidth = width / 4; + const quarterHeight = height / 4; + + const edges = [ + { side: "left", dist: clientX - panelsRect.left, threshold: quarterWidth }, + { side: "right", dist: panelsRect.right - clientX, threshold: quarterWidth }, + { side: "top", dist: clientY - panelsRect.top, threshold: quarterHeight }, + { side: "bottom", dist: panelsRect.bottom - clientY, threshold: quarterHeight }, + ]; + + let closestEdge = null; + let minDist = Infinity; + for (const edge of edges) { + if (edge.dist < edge.threshold && edge.dist < minDist) { + minDist = edge.dist; + closestEdge = edge; + } + } + + return closestEdge ? closestEdge.side : null; + } + // eslint-disable-next-line complexity onBrowserDragOverToSplit(event) { gBrowser.tabContainer.tabDragAndDrop.clearSpaceSwitchTimer(); @@ -303,6 +334,7 @@ class nsZenViewSplitter extends nsZenDOMOperatedFeature { } const panelsRect = gBrowser.tabbox.getBoundingClientRect(); const panelsWidth = panelsRect.width; + const panelsHeight = panelsRect.height; if ( event.clientX > panelsRect.left + panelsWidth - 10 || event.clientX < panelsRect.left + 10 || @@ -311,11 +343,17 @@ class nsZenViewSplitter extends nsZenDOMOperatedFeature { ) { return; } + const dropSide = this._calculateDropSide(event, panelsRect); + if (!dropSide) { + return; + } // first quarter or last quarter of the screen, but not the middle if ( !( event.clientX < panelsRect.left + panelsWidth / 4 || - event.clientX > panelsRect.left + (panelsWidth / 4) * 3 + event.clientX > panelsRect.left + (panelsWidth / 4) * 3 || + event.clientY < panelsRect.top + panelsHeight / 4 || + event.clientY > panelsRect.top + (panelsHeight / 4) * 3 ) ) { return; @@ -336,93 +374,113 @@ class nsZenViewSplitter extends nsZenDOMOperatedFeature { this._canDrop = true; // eslint-disable-next-line mozilla/valid-services Services.zen.playHapticFeedback(); - { - this._draggingTab = draggedTab; - gBrowser.selectedTab = oldTab; - this._hasAnimated = true; - this.tabBrowserPanel.setAttribute("dragging-split", "true"); - // Add a min width to all the browser elements to prevent them from resizing - // eslint-disable-next-line no-shadow - const panelsWidth = gBrowser.tabbox.getBoundingClientRect().width; - let numOfTabsToDivide = 2; - if (currentView) { - numOfTabsToDivide = currentView.tabs.length + 1; + this._draggingTab = draggedTab; + gBrowser.selectedTab = oldTab; + this._hasAnimated = true; + this.tabBrowserPanel.setAttribute("dragging-split", "true"); + this._animateDropEdge(dropSide, currentView, draggedTab, oldTab); + } + + _animateDropEdge(dropSide, currentView, draggedTab, oldTab) { + // Add a min width to all the browser elements to prevent them from resizing + // eslint-disable-next-line no-shadow + const { height, width } = gBrowser.tabbox.getBoundingClientRect(); + let numOfTabsToDivide = 2; + if (currentView) { + numOfTabsToDivide = currentView.tabs.length + 1; + } + const halfWidth = width / numOfTabsToDivide; + const halfHeight = height / numOfTabsToDivide; + const side = dropSide; + for (const browser of gBrowser.browsers) { + if (!browser) { + continue; } - const halfWidth = panelsWidth / numOfTabsToDivide; - let threshold = - gNavToolbox.getBoundingClientRect().width * - (gZenVerticalTabsManager._prefsRightSide ? 0 : 1); - if (gZenCompactModeManager.preference) { - threshold = 0; + const { width: browserWidth, height: browserHeight } = browser.getBoundingClientRect(); + // Only apply it to the left side because if we add it to the right side, + // we wont be able to move the element to the left. + // FIXME: This is a workaround, we should find a better way to do this + switch (side) { + case "left": + browser.style.minWidth = `${browserWidth}px`; + break; + case "top": + browser.style.minHeight = `${browserHeight}px`; + break; } - const side = event.clientX - threshold > halfWidth ? "right" : "left"; - for (const browser of gBrowser.browsers) { - if (!browser) { - continue; + } + this.fakeBrowser = document.createXULElement("vbox"); + window.addEventListener("dragend", this.onBrowserDragEndToSplit, { once: true }); + const padding = ZenThemeModifier.elementSeparation; + this.fakeBrowser.setAttribute("flex", "1"); + this.fakeBrowser.id = "zen-split-view-fake-browser"; + if (oldTab.splitView) { + this.fakeBrowser.setAttribute("has-split-view", "true"); + } + gBrowser.tabbox.appendChild(this.fakeBrowser); + this.fakeBrowser.setAttribute("side", side); + let animateTabBox = null; + let animateFakeBrowser = null; + switch (side) { + case "left": + animateTabBox = { + padding: [0, `0 0 0 ${halfWidth}px`], + }; + animateFakeBrowser = { + width: [0, `${halfWidth - padding}px`], + margin: [0, `0 0 0 ${-halfWidth}px`], + }; + break; + case "right": + animateTabBox = { + padding: [0, `0 ${halfWidth}px 0 0`], + }; + animateFakeBrowser = { + width: [0, `${halfWidth - padding}px`], + }; + break; + + case "top": + animateTabBox = { + padding: [0, `${halfHeight}px 0 0 0`], + }; + animateFakeBrowser = { + height: [0, `${halfHeight - padding}px`], + margin: [0, `${-halfHeight}px 0 0 0`], + }; + break; + + case "bottom": + animateTabBox = { + padding: [0, `0 0 ${halfHeight}px 0`], + }; + animateFakeBrowser = { + height: [0, `${halfHeight - padding}px`], + }; + break; + } + + this._finishAllAnimatingPromise = Promise.all([ + gZenUIManager.motion.animate(gBrowser.tabbox, animateTabBox, { + duration: 0.1, + easing: "ease-out", + }), + gZenUIManager.motion.animate(this.fakeBrowser, animateFakeBrowser, { + duration: 0.1, + easing: "ease-out", + }), + ]); + if (this._finishAllAnimatingPromise) { + this._finishAllAnimatingPromise.then(() => { + if (draggedTab !== oldTab) { + draggedTab.linkedBrowser.docShellIsActive = false; + draggedTab.linkedBrowser + .closest(".browserSidebarContainer") + .classList.remove("deck-selected"); } - const width = browser.getBoundingClientRect().width; - // Only apply it to the left side because if we add it to the right side, - // we wont be able to move the element to the left. - // FIXME: This is a workaround, we should find a better way to do this - if (side === "left") { - browser.style.minWidth = `${width}px`; - } - } - this.fakeBrowser = document.createXULElement("vbox"); - window.addEventListener("dragend", this.onBrowserDragEndToSplit, { once: true }); - const padding = ZenThemeModifier.elementSeparation; - this.fakeBrowser.setAttribute("flex", "1"); - this.fakeBrowser.id = "zen-split-view-fake-browser"; - if (oldTab.splitView) { - this.fakeBrowser.setAttribute("has-split-view", "true"); - } - gBrowser.tabbox.appendChild(this.fakeBrowser); - this.fakeBrowser.setAttribute("side", side); - this._finishAllAnimatingPromise = Promise.all([ - gZenUIManager.motion.animate( - gBrowser.tabbox, - side === "left" - ? { - paddingLeft: [0, `${halfWidth}px`], - paddingRight: 0, - } - : { - paddingRight: [0, `${halfWidth}px`], - paddingLeft: 0, - }, - { - duration: 0.1, - easing: "ease-out", - } - ), - gZenUIManager.motion.animate( - this.fakeBrowser, - { - width: [0, `${halfWidth - padding}px`], - ...(side === "left" - ? { - marginLeft: [0, `${-halfWidth}px`], - } - : {}), - }, - { - duration: 0.1, - easing: "ease-out", - } - ), - ]); - if (this._finishAllAnimatingPromise) { - this._finishAllAnimatingPromise.then(() => { - if (draggedTab !== oldTab) { - draggedTab.linkedBrowser.docShellIsActive = false; - draggedTab.linkedBrowser - .closest(".browserSidebarContainer") - .classList.remove("deck-selected"); - } - this.fakeBrowser.addEventListener("dragleave", this.onBrowserDragEndToSplit); - this._canDrop = true; - }); - } + this.fakeBrowser.addEventListener("dragleave", this.onBrowserDragEndToSplit); + this._canDrop = true; + }); } } @@ -447,12 +505,14 @@ class nsZenViewSplitter extends nsZenDOMOperatedFeature { return; } const panelsWidth = panelsRect.width; + const panelsHeight = panelsRect.height; let numOfTabsToDivide = 2; const currentView = this._data[this._lastOpenedTab.splitViewValue]; if (currentView) { numOfTabsToDivide = currentView.tabs.length + 1; } const halfWidth = panelsWidth / numOfTabsToDivide; + const halfHeight = panelsHeight / numOfTabsToDivide; const padding = ZenThemeModifier.elementSeparation; if (!this.fakeBrowser) { return; @@ -464,39 +524,60 @@ class nsZenViewSplitter extends nsZenDOMOperatedFeature { ...gBrowser.tabContainer.tabDragAndDrop.originalDragImageArgs ); this._canDrop = false; - Promise.all([ - gZenUIManager.motion.animate( - gBrowser.tabbox, - side === "left" - ? { - paddingLeft: [`${halfWidth}px`, 0], - } - : { - paddingRight: [`${halfWidth}px`, 0], - }, - { - duration: 0.1, - easing: "ease-out", - } - ), - gZenUIManager.motion.animate( - this.fakeBrowser, - { - width: [`${halfWidth - padding * 2}px`, 0], - ...(side === "left" - ? { - marginLeft: [`${-halfWidth}px`, 0], - } - : {}), - }, - { - duration: 0.1, - easing: "ease-out", - } - ), - ]).finally(() => { - this._maybeRemoveFakeBrowser(); - }); + let animateTabBox = null; + let animateFakeBrowser = null; + switch (side) { + case "left": + animateTabBox = { + padding: [`0 0 0 ${halfWidth}px`, 0], + }; + animateFakeBrowser = { + width: [`${halfWidth - padding}px`, 0], + margin: [`0 0 0 ${-halfWidth}px`, 0], + }; + break; + case "right": + animateTabBox = { + padding: [`0 ${halfWidth}px 0 0`, 0], + }; + animateFakeBrowser = { + width: [`${halfWidth - padding}px`, 0], + }; + break; + case "top": + animateTabBox = { + padding: [`${halfHeight}px 0 0 0`, 0], + }; + animateFakeBrowser = { + height: [`${halfHeight - padding}px`, 0], + margin: [`${-halfHeight}px 0 0 0`, 0], + }; + break; + case "bottom": + animateTabBox = { + padding: [`0 0 ${halfHeight}px 0`, 0], + }; + animateFakeBrowser = { + height: [`${halfHeight - padding}px`, 0], + }; + break; + } + + this._finishAllAnimatingPromise = Promise.all([ + gZenUIManager.motion.animate(gBrowser.tabbox, animateTabBox, { + duration: 0.1, + easing: "ease-out", + }), + gZenUIManager.motion.animate(this.fakeBrowser, animateFakeBrowser, { + duration: 0.1, + easing: "ease-out", + }), + ]); + if (this._finishAllAnimatingPromise) { + this._finishAllAnimatingPromise.then(() => { + this._maybeRemoveFakeBrowser(); + }); + } } /** @@ -1845,12 +1926,24 @@ class nsZenViewSplitter extends nsZenDOMOperatedFeature { const dropSide = this.fakeBrowser?.getAttribute("side"); const containerRect = this.fakeBrowser.getBoundingClientRect(); const padding = ZenThemeModifier.elementSeparation; - const dropTarget = document.elementFromPoint( - dropSide === "left" - ? containerRect.left + containerRect.width + padding + 5 - : containerRect.left - padding - 5, - event.clientY - ); + let targetX = event.clientX; + let targetY = event.clientY; + switch (dropSide) { + case "left": + targetX = containerRect.left + containerRect.width + padding + 5; + break; + case "right": + targetX = containerRect.left - padding - 5; + break; + case "top": + targetY = containerRect.top + containerRect.height + padding + 5; + break; + case "bottom": + targetY = containerRect.top - padding - 5; + break; + } + + const dropTarget = document.elementFromPoint(targetX, targetY); const browser = dropTarget?.closest("browser") ?? dropTarget?.closest(".browserSidebarContainer")?.querySelector("browser"); @@ -1862,7 +1955,7 @@ class nsZenViewSplitter extends nsZenDOMOperatedFeature { let droppedOnTab = gZenGlanceManager.getTabOrGlanceParent(gBrowser.getTabForBrowser(browser)); if (droppedOnTab === this._draggingTab) { - this.createEmptySplit(dropSide == "right"); + this.createEmptySplit(dropSide); return true; } @@ -1919,29 +2012,21 @@ class nsZenViewSplitter extends nsZenDOMOperatedFeature { } } } - - const droppedOnSplitNode = this.getSplitNodeFromTab(droppedOnTab); - const parentNode = droppedOnSplitNode.parent; - // Then add the tab to the split view group.tabs.push(draggedTab); - // If dropping on a side, create a new split in that direction + // If dropping on a side, wrap entire layout in a new split at the root level if (hoverSide !== "center") { const splitDirection = hoverSide === "left" || hoverSide === "right" ? "row" : "column"; - if (parentNode.direction !== splitDirection) { - this.splitIntoNode( - droppedOnSplitNode, - new nsSplitLeafNode(draggedTab, 50), - hoverSide, - 0.5 - ); + const rootNode = group.layoutTree; + const prepend = hoverSide === "left" || hoverSide === "top"; + + if (rootNode.direction === splitDirection) { + // Root has the same direction, add as a new child of the root + this.addTabToSplit(draggedTab, rootNode, prepend); } else { - this.addTabToSplit( - draggedTab, - parentNode, - /* prepend = */ hoverSide === "left" || hoverSide === "top" - ); + // Different direction, wrap root in a new split node + this.splitIntoNode(rootNode, new nsSplitLeafNode(draggedTab, 50), hoverSide, 0.5); } } else { this.addTabToSplit(draggedTab, group.layoutTree); @@ -1951,13 +2036,14 @@ class nsZenViewSplitter extends nsZenDOMOperatedFeature { } } else { // Create new split view with layout based on drop position - let gridType = "vsep"; + const gridType = dropSide === "top" || dropSide === "bottom" ? "hsep" : "vsep"; + const topOrLeft = dropSide === "top" || dropSide === "left"; // Put tabs always as if it was dropped from the left this.splitTabs( - dropSide == "left" ? [draggedTab, droppedOnTab] : [droppedOnTab, draggedTab], + topOrLeft ? [draggedTab, droppedOnTab] : [droppedOnTab, draggedTab], gridType, - dropSide == "left" ? 0 : 1 + topOrLeft ? 0 : 1 ); } @@ -2192,14 +2278,16 @@ class nsZenViewSplitter extends nsZenDOMOperatedFeature { } } - createEmptySplit(rightSide = true) { + createEmptySplit(side = "right") { const selectedTab = gBrowser.selectedTab; const emptyTab = gZenWorkspaces._emptyTab; - let tabs = rightSide ? [selectedTab, emptyTab] : [emptyTab, selectedTab]; + const gridType = side === "top" || side === "bottom" ? "hsep" : "vsep"; + const topOrLeft = side === "top" || side === "left"; + let tabs = topOrLeft ? [emptyTab, selectedTab] : [selectedTab, emptyTab]; const data = { tabs, - gridType: "grid", - layoutTree: this.calculateLayoutTree(tabs, "grid"), + gridType, + layoutTree: this.calculateLayoutTree(tabs, gridType), }; this.#withoutSplitViewTransition(() => { this._data.push(data); @@ -2234,9 +2322,9 @@ class nsZenViewSplitter extends nsZenDOMOperatedFeature { gBrowser.selectedTab = selectedTab; this.resetTabState(emptyTab, false); this.splitTabs( - rightSide ? [selectedTab, newSelectedTab] : [newSelectedTab, selectedTab], - "grid", - rightSide ? 1 : 0 + topOrLeft ? [newSelectedTab, selectedTab] : [selectedTab, newSelectedTab], + gridType, + topOrLeft ? 0 : 1 ); } else { cleanup(); diff --git a/src/zen/split-view/zen-decks.css b/src/zen/split-view/zen-decks.css index c9e52426f..98a553897 100644 --- a/src/zen/split-view/zen-decks.css +++ b/src/zen/split-view/zen-decks.css @@ -44,12 +44,12 @@ #zen-splitview-dropzone { position: absolute !important; - margin: var(--zen-split-column-gap) var(--zen-split-row-gap) !important; - margin-bottom: 0 !important; - margin-left: 0 !important; + margin: var(--zen-split-column-gap) var(--zen-split-row-gap); + margin-bottom: 0; + margin-left: 0; &.browserSidebarContainer:not([zen-split='true']) { - margin-top: 0 !important; + margin-top: 0; visibility: hidden; } } @@ -204,6 +204,15 @@ overflow: hidden; will-change: width, margin-left; + &[side='top'], + &[side='bottom'] { + width: 100%; + + &[has-split-view='true'] { + width: calc(100% - var(--zen-element-separation)); + } + } + &[side='right'] { right: 0; @@ -211,6 +220,10 @@ right: var(--zen-element-separation); } } + + &[side='bottom'] { + bottom: 0; + } } #zen-split-view-drag-image {