Skip to content
Open
9 changes: 9 additions & 0 deletions prefs/zen/split-view.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: 100

- name: zen.splitView.drag-over-split-threshold
value: 25
178 changes: 178 additions & 0 deletions src/zen/drag-and-drop/ZenDragAndDrop.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@
#changeSpaceTimer = null;
#isAnimatingTabMove = false;

#dragOverSplitTimer = null;
#fakeTabSplit = null;

constructor(tabbrowserTabs) {
super(tabbrowserTabs);

Expand Down Expand Up @@ -572,6 +575,7 @@
return;
}
this.#handle_sidebarDragOver(event);
this.#handle_tabDragOverToSplit(event);
}

#shouldSwitchSpace(event) {
Expand Down Expand Up @@ -622,6 +626,144 @@
}
}

#handle_tabDragOverToSplit(event) {
if (!Services.prefs.getBoolPref("zen.splitView.enable-drag-over-split", true)) {
return;
}

const dt = event.dataTransfer;
const draggedTab = dt.mozGetDataAt(TAB_DROP_TYPE, 0);
let dragData = draggedTab._dragData;
let zenDragData = draggedTab._zenDragData;
let movingTabsSet = dragData.movingTabsSet;
let dropElement = dragData.dropElement;

if (!dropElement || !isTab(dropElement) || dropElement.hasAttribute("zen-glance-tab")) {
return;
}

if (
movingTabsSet.has(dropElement) ||
!isTab(draggedTab) ||
draggedTab?.group?.hasAttribute("split-view-group")
) {
this._maybeClearDragOverSplit();
return;
}

let bounds = window.windowUtils.getBoundsWithoutFlushing(dropElement);
const { clientX, clientY } = event;
let targetX = bounds.x;
let targetY = bounds.y;
let targetWidth = bounds.width;
let targetHeight = bounds.height;

let edgeZoneThreshold =
Services.prefs.getIntPref("zen.splitView.drag-over-split-threshold", 25) / 100;

const topThreshold = targetY + targetHeight * edgeZoneThreshold;
const bottomThreshold = targetY + targetHeight * (1 - edgeZoneThreshold);
if (clientY < topThreshold || clientY > bottomThreshold) {
this._maybeClearDragOverSplit(zenDragData);
return;
}

let isLeft = clientX < targetX + targetWidth / 2;
let dropSide = isLeft ? "left" : "right";

// If the drop side or element changes, clear the timer and recreate the fake tab
if (
zenDragData?.splitDropElement !== dropElement ||
zenDragData?.splitDropSide !== dropSide
) {
this._maybeClearDragOverSplit(zenDragData);
}

if (
this.#dragOverSplitTimer &&
zenDragData?.splitDropElement === dropElement &&
zenDragData?.splitDropSide === dropSide
) {
// Timer already running for the same target and side, do nothing
return;
}

const dragOverSplitDelay = Services.prefs.getIntPref(
"zen.splitView.drag-over-split-delayMC",
100
);
this.#dragOverSplitTimer = setTimeout(() => {
this._createFakeTabSplit(dropElement, dropSide, dragData);
draggedTab._zenDragData = {
splitDropElement: dropElement,
splitDropSide: dropSide,
};
}, dragOverSplitDelay);
}

_createFakeTabSplit(dropElement, dropSide, dragData) {
if (this.#dragOverSplitTimer) {
clearTimeout(this.#dragOverSplitTimer);
this.#dragOverSplitTimer = null;
}

// Remove drop indicator
this.clearDragOverVisuals();

// Remove any existing fake split tab
if (this.#fakeTabSplit) {
this.#fakeTabSplit.remove();
}
const draggedTab = dragData.movingTabs[0];

const element = document.createXULElement("zen-split-fake-tab");
const icon = document.createElement("img");
icon.className = "fake-tab-icon";
let tabURL = draggedTab.linkedBrowser?.currentURI?.spec || "";
try {
// Get the hostname from the URL
const url = new URL(tabURL);
tabURL = url.hostname || tabURL;
} catch {
// We don't need to do anything if the URL is invalid. e.g. about:blank
}
let tabLabel = draggedTab.label || "";
let iconURL = gBrowser.getIcon(draggedTab) || PlacesUtils.favicons.defaultFavicon.spec;
icon.src = iconURL;
const label = document.createElement("label");
label.className = "fake-tab-label";
label.textContent = tabLabel;
element.append(icon, label);

element.style.width = `${dragData.tabWidth / 2}px`;
switch (dropSide) {
case "left":
dropElement.childNodes[0].before(element);
break;
case "right":
dropElement.childNodes[0].after(element);
break;
}

this.#fakeTabSplit = element;
}

_maybeClearDragOverSplit(dragData = null) {
if (this.#dragOverSplitTimer) {
clearTimeout(this.#dragOverSplitTimer);
this.#dragOverSplitTimer = null;
}
if (this.#fakeTabSplit) {
this.#fakeTabSplit.remove();
this.#fakeTabSplit = null;
}
// Clear the stored drop info from dragData
if (dragData) {
delete dragData.splitDropElement;
delete dragData.splitDropSide;
}
}

handle_windowDragEnter(event) {
if (!this.#isMovingTab() || !this.#isOutOfWindow) {
return;
Expand Down Expand Up @@ -680,6 +822,24 @@
this.clearSpaceSwitchTimer();
super.handle_drop(event);
this.#maybeClearVerticalPinnedGridDragOver();
this.#handele_dropSwitchSpace(event);
this.#handle_dropCreateSplit(event);
}

handle_dragleave(event) {
super.handle_dragleave(event);
const dt = event.dataTransfer;
let draggedTab = dt.mozGetDataAt(TAB_DROP_TYPE, 0);
let dragData = draggedTab._dragData;
let zenDragData = draggedTab._zenDragData;
let dropElement = dragData.dropElement;
let splitDropElement = zenDragData?.splitDropElement;
if (splitDropElement && splitDropElement !== dropElement) {
this._maybeClearDragOverSplit(zenDragData);
}
}

#handele_dropSwitchSpace(event) {
const dt = event.dataTransfer;
const activeWorkspace = gZenWorkspaces.activeWorkspace;
let draggedTab = dt.mozGetDataAt(TAB_DROP_TYPE, 0);
Expand All @@ -701,6 +861,24 @@
gZenWorkspaces.updateTabsContainers();
}

#handle_dropCreateSplit(event) {
let dt = event.dataTransfer;
let draggedTab = dt.mozGetDataAt(TAB_DROP_TYPE, 0);
let dragData = draggedTab._zenDragData;
const droppedOnTab = dragData?.splitDropElement;
const dropSide = dragData?.splitDropSide;

this._maybeClearDragOverSplit(dragData); // Clear any visuals and timer

if (draggedTab && droppedOnTab) {
gZenViewSplitter.splitTabs(
dropSide == "left" ? [draggedTab, droppedOnTab] : [droppedOnTab, draggedTab],
"vsep",
dropSide == "left" ? 0 : 1
);
}
}

handle_drop_transition(dropElement, draggedTab, movingTabs, dropBefore) {
if (isTabGroupLabel(dropElement)) {
dropElement = dropElement.group;
Expand Down
42 changes: 42 additions & 0 deletions src/zen/split-view/zen-decks.css
Original file line number Diff line number Diff line change
Expand Up @@ -236,3 +236,45 @@
text-align: center;
}
}

zen-split-fake-tab {
border-radius: var(--tab-border-radius);
margin: 4px -1px 4px 4px;
background-color: color-mix(
in srgb,
var(--zen-toolbar-element-bg),
transparent 40%
);
.fake-tab-icon {
width: 16px;
height: 16px;
border-radius: 4px;
flex-shrink: 0;
margin-top: 8px;
margin-inline-end: 10px;
margin-inline-start: 8px;
fill: currentColor;
-moz-context-properties: fill;
}
.fake-tab-label {
margin-top: 6px;
position: relative;
white-space: nowrap;
overflow: hidden;
text-overflow: clip;
mask-image: linear-gradient(to left, transparent, black var(--tab-label-mask-size));
width: 250px;
}
}

.tabbrowser-tab:has(zen-split-fake-tab) {
height: 40px;
background: color-mix(
in srgb,
var(--zen-toolbar-element-bg),
transparent 40%
);
border-radius: var(--border-radius-medium);
background-color: var(--tab-selected-bgcolor);
box-shadow: var(--tab-selected-shadow);
}