From 1554818aa8b6216ab6297c6a6ac005eb5d258942 Mon Sep 17 00:00:00 2001 From: Andrey Bochkarev <50177704+octaviusz@users.noreply.github.com> Date: Wed, 25 Feb 2026 01:29:44 +0300 Subject: [PATCH] feat: Add support for creating split view with dnd, p=#11928, c=tabs Co-authored-by: mr. m <91018726+mr-cheffy@users.noreply.github.com> Co-authored-by: mr. m --- prefs/zen/split-view.yaml | 9 ++ src/zen/drag-and-drop/ZenDragAndDrop.js | 173 ++++++++++++++++++++++++ src/zen/split-view/zen-decks.css | 47 ++++--- 3 files changed, 208 insertions(+), 21 deletions(-) diff --git a/prefs/zen/split-view.yaml b/prefs/zen/split-view.yaml index 118125f19..fa4fc658f 100644 --- a/prefs/zen/split-view.yaml +++ b/prefs/zen/split-view.yaml @@ -10,3 +10,12 @@ - name: zen.splitView.rearrange-hover-size value: 24 + +- name: zen.splitView.enable-drag-over-split + value: true + +- name: zen.splitView.drag-over-split-delayMC + value: 300 + +- name: zen.splitView.drag-over-split-threshold + value: 25 diff --git a/src/zen/drag-and-drop/ZenDragAndDrop.js b/src/zen/drag-and-drop/ZenDragAndDrop.js index 013c2919c..5b77f7847 100644 --- a/src/zen/drag-and-drop/ZenDragAndDrop.js +++ b/src/zen/drag-and-drop/ZenDragAndDrop.js @@ -68,6 +68,8 @@ #changeSpaceTimer = null; #isAnimatingTabMove = false; + #dragOverSplit = {}; + constructor(tabbrowserTabs) { super(tabbrowserTabs); @@ -78,6 +80,24 @@ Ci.nsIZenDragAndDrop ); + XPCOMUtils.defineLazyPreferenceGetter( + this, + "_dndSplitEnabled", + "zen.splitView.enable-drag-over-split", + true + ); + XPCOMUtils.defineLazyPreferenceGetter( + this, + "_dndSplitThreshold", + "zen.splitView.drag-over-split-threshold", + 25 + ); + XPCOMUtils.defineLazyPreferenceGetter( + this, + "_dndSplitDelay", + "zen.splitView.drag-over-split-delayMC", + 300 + ); XPCOMUtils.defineLazyPreferenceGetter( this, "_dndSwitchSpaceDelay", @@ -587,6 +607,7 @@ return; } this.#handle_sidebarDragOver(event); + this.#handle_tabDragOverToSplit(event); } #shouldSwitchSpace(event) { @@ -655,6 +676,119 @@ } } + #handle_tabDragOverToSplit(event) { + if (!this._dndSplitEnabled) { + return; + } + + const dt = event.dataTransfer; + const draggedTab = dt.mozGetDataAt(TAB_DROP_TYPE, 0); + const dragData = draggedTab._dragData; + const movingTabsSet = dragData.movingTabsSet; + const dropElement = event.target.closest(".tabbrowser-tab"); + + // TODO: After Cheff adds split view support for essentials, don't forget to remove the check + if ( + !dropElement || + !isTab(dropElement) || + dropElement.hasAttribute("zen-essential") || + dropElement.hasAttribute("zen-glance-tab") || + dropElement?.group?.hasAttribute("split-view-group") || + movingTabsSet.size > 1 + ) { + this._clearDragOverSplit(); + return; + } + + if ( + movingTabsSet.has(dropElement) || + !isTab(draggedTab) || + draggedTab?.group?.hasAttribute("split-view-group") + ) { + this._clearDragOverSplit(); + return; + } + + const rect = window.windowUtils.getBoundsWithoutFlushing(dropElement); + const { clientX, clientY } = event; + const targetX = rect.x; + const targetTop = rect.top; + const targetWidth = rect.width; + const targetHeight = rect.height; + + const edgeZoneThreshold = this._dndSplitThreshold / 100; + + const overlapRatioY = (clientY - targetTop) / targetHeight; + const overlapRatioX = (clientX - targetX) / targetWidth; + if ( + (overlapRatioX > edgeZoneThreshold && overlapRatioX < 1 - edgeZoneThreshold) || + overlapRatioY < edgeZoneThreshold || + overlapRatioY > 1 - edgeZoneThreshold + ) { + this._clearDragOverSplit(); + return; + } + + const isLeft = clientX < targetX + targetWidth / 2; + const dropSide = isLeft ? "left" : "right"; + + // If the drop side or element changes, clear dragOverSplit + if ( + this.#dragOverSplit.data?.dropElement !== dropElement || + this.#dragOverSplit.data?.dropSide !== dropSide + ) { + this._clearDragOverSplit(); + } + + if ( + this.#dragOverSplit.timer && + this.#dragOverSplit.data?.dropElement === dropElement && + this.#dragOverSplit.data?.dropSide === dropSide + ) { + // Timer already running for the same target and side, do nothing + return; + } + + this.#dragOverSplit.data = { + dropElement, + dropSide, + }; + this.#dragOverSplit.timer = setTimeout(() => { + this.#createFakeTabSplit(dropElement, dropSide); + }, this._dndSplitDelay); + } + + #createFakeTabSplit(dropElement, dropSide) { + // Remove drop indicator + this.clearDragOverVisuals(); + + // Remove any existing fake tab + if (this.#dragOverSplit.fakeTab) { + this.#dragOverSplit.fakeTab.remove(); + } + + const element = document.createXULElement("zen-split-fake-tab"); + const firstChild = dropElement.firstChild; + if (dropSide === "left") { + firstChild.before(element); + } else { + firstChild.after(element); + } + + this.#dragOverSplit.fakeTab = element; + } + + _clearDragOverSplit() { + if (this.#dragOverSplit.timer) { + clearTimeout(this.#dragOverSplit.timer); + } + this.#dragOverSplit.fakeTab?.remove(); + + this.#dragOverSplit.timer = null; + this.#dragOverSplit.fakeTab = null; + this.#dragOverSplit.data = null; + } + handle_windowDragEnter(event) { if (!this.#isMovingTab() || !this.#isOutOfWindow) { return; @@ -722,6 +856,17 @@ gZenFolders.highlightGroupOnDragOver(null); super.handle_drop(event); this.#maybeClearVerticalPinnedGridDragOver(); + this.#handle_dropSwitchSpace(event); + this.#handle_dropCreateSplit(event); + this._clearDragOverSplit(); + } + + handle_dragleave(event) { + super.handle_dragleave(event); + this._clearDragOverSplit(); + } + + #handle_dropSwitchSpace(event) { const dt = event.dataTransfer; const activeWorkspace = gZenWorkspaces.activeWorkspace; let draggedTab = dt.mozGetDataAt(TAB_DROP_TYPE, 0); @@ -745,6 +890,29 @@ gZenWorkspaces.updateTabsContainers(); } + #handle_dropCreateSplit(event) { + const dragData = this.#dragOverSplit.data; + const dt = event.dataTransfer; + const draggedTab = dt.mozGetDataAt(TAB_DROP_TYPE, 0); + + if (!dragData || !draggedTab) { + return; + } + + const droppedOnTab = dragData.dropElement; + const dropSide = dragData.dropSide; + + // Clear any visuals and timer + this._clearDragOverSplit(); + + const isLeft = dropSide === "left"; + gZenViewSplitter.splitTabs( + isLeft ? [draggedTab, droppedOnTab] : [droppedOnTab, draggedTab], + "vsep", + isLeft ? 0 : 1 + ); + } + handle_drop_transition(dropElement, draggedTab, movingTabs, dropBefore) { if (isTabGroupLabel(dropElement)) { dropElement = dropElement.group; @@ -874,6 +1042,7 @@ super.handle_dragend(event); thisFromGlobal.clearDragOverVisuals(); ownerGlobal.gZenPinnedTabManager.removeTabContainersDragoverClass(); + thisFromGlobal._clearDragOverSplit(); this.#maybeClearVerticalPinnedGridDragOver(); thisFromGlobal.originalDragImageArgs = []; window.removeEventListener("dragenter", thisFromGlobal.handle_windowDragEnter, { @@ -948,6 +1117,10 @@ // eslint-disable-next-line complexity #applyDragoverIndicator(event, dropElement, movingTabs, draggedTab) { + // Doesn't show indicator when dragOverSplit + if (this.#dragOverSplit.data) { + return; + } const separation = 4; const dropZoneSelector = ":is(.zen-drop-target)"; let shouldPlayHapticFeedback = false; diff --git a/src/zen/split-view/zen-decks.css b/src/zen/split-view/zen-decks.css index 98a553897..eeb9ab27e 100644 --- a/src/zen/split-view/zen-decks.css +++ b/src/zen/split-view/zen-decks.css @@ -8,13 +8,13 @@ %include zen-split-group.inc.css -#tabbrowser-tabpanels[zen-split-view='true'] { +#tabbrowser-tabpanels[zen-split-view="true"] { display: flex; flex-direction: row; margin-top: calc(var(--zen-split-column-gap) * -1); } -#tabbrowser-tabpanels[zen-split-view='true'] > *:not([zen-split='true']) { +#tabbrowser-tabpanels[zen-split-view="true"] > *:not([zen-split="true"]) { flex: 0; margin: 0; } @@ -25,11 +25,11 @@ display: none; background-color: color-mix(in srgb, var(--zen-colors-secondary) 30%, transparent 70%); } -#zen-splitview-dropzone[enabled='true'] { +#zen-splitview-dropzone[enabled="true"] { display: initial; } -#tabbrowser-tabpanels[zen-split-view='true'] > [zen-split='true'], +#tabbrowser-tabpanels[zen-split-view="true"] > [zen-split="true"], #zen-splitview-dropzone { flex: 1; overflow: hidden; @@ -40,7 +40,7 @@ } } -.browserSidebarContainer[is-zen-split='true'], +.browserSidebarContainer[is-zen-split="true"], #zen-splitview-dropzone { position: absolute !important; @@ -48,7 +48,7 @@ margin-bottom: 0; margin-left: 0; - &.browserSidebarContainer:not([zen-split='true']) { + &.browserSidebarContainer:not([zen-split="true"]) { margin-top: 0; visibility: hidden; } @@ -58,7 +58,7 @@ margin-top: 0 !important; } -#tabbrowser-tabpanels[zen-split-view='true']:not(.zen-split-view-no-transition):not([zen-split-resizing]) > [zen-split='true'] { +#tabbrowser-tabpanels[zen-split-view="true"]:not(.zen-split-view-no-transition):not([zen-split-resizing]) > [zen-split="true"] { --zen-active-split-outline-color: light-dark(var(--zen-primary-color), var(--button-background-color-primary)); transition: inset 0.09s ease-out !important; @@ -67,7 +67,7 @@ } } -#tabbrowser-tabpanels[zen-split-view='true'] .browserSidebarContainer.deck-selected { +#tabbrowser-tabpanels[zen-split-view="true"] .browserSidebarContainer.deck-selected { &:not(.zen-glance-overlay) { outline: 2px solid var(--zen-active-split-outline-color) !important; } @@ -84,8 +84,8 @@ --zen-split-column-gap: calc(var(--zen-element-separation) + 1px); } -#tabbrowser-tabbox[zen-split-view='true'] { - :root[zen-no-padding='true'] & { +#tabbrowser-tabbox[zen-split-view="true"] { + :root[zen-no-padding="true"] & { --zen-element-separation: 8px; } margin-right: calc(-1 * var(--zen-split-column-gap)); @@ -121,7 +121,7 @@ pointer-events: all; } -.zen-split-view-splitter[orient='horizontal'] { +.zen-split-view-splitter[orient="horizontal"] { height: var(--zen-split-column-gap); cursor: ns-resize; } @@ -152,9 +152,7 @@ border-top-right-radius: 0; } -:root:not([inDOMFullscreen='true']) - .browserSidebarContainer:hover - .zen-view-splitter-header-container, +:root:not([inDOMFullscreen="true"]) .browserSidebarContainer:hover .zen-view-splitter-header-container, .zen-view-splitter-header-container:hover { pointer-events: all; opacity: 1; @@ -204,24 +202,24 @@ overflow: hidden; will-change: width, margin-left; - &[side='top'], - &[side='bottom'] { + &[side="top"], + &[side="bottom"] { width: 100%; - - &[has-split-view='true'] { + + &[has-split-view="true"] { width: calc(100% - var(--zen-element-separation)); } } - &[side='right'] { + &[side="right"] { right: 0; - &[has-split-view='true'] { + &[has-split-view="true"] { right: var(--zen-element-separation); } } - &[side='bottom'] { + &[side="bottom"] { bottom: 0; } } @@ -249,3 +247,10 @@ text-align: center; } } + +zen-split-fake-tab { + border-radius: var(--tab-border-radius); + background-color: color-mix(in srgb, var(--button-background-color-primary), transparent 40%); + margin: var(--tab-block-margin); + flex: 1; +}