Skip to content

Commit 81ec621

Browse files
committed
feat: Drag and Drop link to split
1 parent 8b25b0b commit 81ec621

File tree

3 files changed

+335
-0
lines changed

3 files changed

+335
-0
lines changed

src/browser/app/profile/features.inc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ pref('zen.workspaces.debug', true);
134134

135135
// Zen Split View
136136
pref('zen.splitView.enable-tab-drop', true);
137+
pref('zen.splitView.enable-link-drop', true);
137138
pref('zen.splitView.min-resize-width', 7);
138139
pref('zen.splitView.rearrange-hover-size', 24);
139140

src/zen/split-view/ZenViewSplitter.mjs

Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ class ZenViewSplitter extends ZenDOMOperatedFeature {
7979

8080
MAX_TABS = 4;
8181

82+
// Link drag and drop
83+
_linkDropZone = null;
84+
_isLinkDragging = false;
85+
8286
init() {
8387
this.handleTabEvent = this._handleTabEvent.bind(this);
8488

@@ -123,6 +127,11 @@ class ZenViewSplitter extends ZenDOMOperatedFeature {
123127
tabBox.addEventListener('dragover', this.onBrowserDragOverToSplit.bind(this));
124128
this.onBrowserDragEndToSplit = this.onBrowserDragEndToSplit.bind(this);
125129
}
130+
131+
// If enabled initialize the link drag and drop
132+
if (Services.prefs.getBoolPref('zen.splitView.enable-link-drop')) {
133+
this.#initLinkDragDropSplit();
134+
}
126135
}
127136

128137
insertIntoContextMenu() {
@@ -1894,6 +1903,279 @@ class ZenViewSplitter extends ZenDOMOperatedFeature {
18941903
}
18951904
return true;
18961905
}
1906+
1907+
#initLinkDragDropSplit() {
1908+
this._handleLinkDragEnter = this._handleLinkDragEnter.bind(this);
1909+
this._handleLinkDragLeave = this._handleLinkDragLeave.bind(this);
1910+
this._handleLinkDragDrop = this._handleLinkDragDrop.bind(this);
1911+
this._handleLinkDragEnd = this._handleLinkDragEnd.bind(this);
1912+
1913+
const tabBox = document.getElementById('tabbrowser-tabbox');
1914+
1915+
tabBox.addEventListener('dragenter', this._handleLinkDragEnter, true);
1916+
tabBox.addEventListener('dragleave', this._handleLinkDragLeave, false);
1917+
tabBox.addEventListener('drop', this._handleLinkDragDrop, false);
1918+
tabBox.addEventListener('dragend', this._handleLinkDragEnd, false);
1919+
}
1920+
1921+
_createLinkDropZone() {
1922+
if (this._linkDropZone) return;
1923+
1924+
this._linkDropZone = document.createXULElement('box');
1925+
this._linkDropZone.id = 'zen-drop-link-zone';
1926+
1927+
const content = document.createXULElement('vbox');
1928+
content.setAttribute('align', 'center');
1929+
content.setAttribute('pack', 'center');
1930+
content.setAttribute('flex', '1');
1931+
1932+
const text = document.createXULElement('description');
1933+
text.setAttribute('value', 'Drop link to split'); // Localization! data-l10n-id
1934+
1935+
content.appendChild(text);
1936+
this._linkDropZone.appendChild(content);
1937+
1938+
this._linkDropZone.addEventListener('dragover', (event) => {
1939+
event.preventDefault();
1940+
event.stopPropagation();
1941+
event.dataTransfer.dropEffect = 'link';
1942+
if (!this._linkDropZone.hasAttribute('has-focus')) {
1943+
this._linkDropZone.setAttribute('has-focus', 'true');
1944+
}
1945+
});
1946+
1947+
this._linkDropZone.addEventListener('dragleave', (event) => {
1948+
event.stopPropagation();
1949+
if (!this._linkDropZone.contains(event.relatedTarget)) {
1950+
this._linkDropZone.removeAttribute('has-focus');
1951+
}
1952+
});
1953+
1954+
this._linkDropZone.addEventListener('drop', this._handleDropForSplit.bind(this));
1955+
1956+
const tabBox = document.getElementById('tabbrowser-tabbox');
1957+
tabBox.appendChild(this._linkDropZone);
1958+
}
1959+
1960+
_showLinkDropZone() {
1961+
if (!this._linkDropZone) this._createLinkDropZone();
1962+
1963+
this._linkDropZone.setAttribute('enabled', 'true');
1964+
}
1965+
1966+
_hideLinkDropZone(force = false) {
1967+
if (!this._linkDropZone || !this._linkDropZone.hasAttribute('enabled')) return;
1968+
1969+
if (this._isLinkDragging && !force) return;
1970+
1971+
this._linkDropZone.removeAttribute('enabled');
1972+
this._linkDropZone.removeAttribute('has-focus');
1973+
}
1974+
1975+
_validateURI(dataTransfer) {
1976+
let dt = dataTransfer;
1977+
1978+
const URL_TYPES = ['text/uri-list', 'text/x-moz-url', 'text/plain'];
1979+
1980+
const FIXUP_FLAGS = Ci.nsIURIFixup.FIXUP_FLAG_FIX_SCHEME_TYPOS;
1981+
1982+
const matchedType = URL_TYPES.find((type) => {
1983+
const raw = dt.getData(type);
1984+
return typeof raw === 'string' && raw.trim().length > 0;
1985+
});
1986+
1987+
const uriString = dt.getData(matchedType).trim();
1988+
1989+
const info = Services.uriFixup.getFixupURIInfo(uriString, FIXUP_FLAGS);
1990+
1991+
if (!info || !info.fixedURI) {
1992+
return null;
1993+
}
1994+
1995+
return info.fixedURI.spec;
1996+
}
1997+
1998+
_handleLinkDragEnter(event) {
1999+
// If rearrangeViewEnabled - don't do anything
2000+
if (this.rearrangeViewEnabled) {
2001+
return;
2002+
}
2003+
2004+
const shouldBeDisabled = !this.canOpenLinkInSplitView();
2005+
if (shouldBeDisabled) return;
2006+
2007+
// If the target is our drop zone or one of its children, or already active, do nothing here.
2008+
if (
2009+
this._linkDropZone &&
2010+
(this._linkDropZone.contains(event.target) || this._linkDropZone.hasAttribute('enabled'))
2011+
) {
2012+
return;
2013+
}
2014+
2015+
// If the data is not a valid URI, we don't want to do anything
2016+
if (!this._validateURI(event.dataTransfer)) {
2017+
return;
2018+
}
2019+
2020+
this._isLinkDragging = true;
2021+
this._showLinkDropZone();
2022+
2023+
event.preventDefault();
2024+
event.stopPropagation();
2025+
}
2026+
2027+
_handleLinkDragLeave(event) {
2028+
if (
2029+
event.target === document.documentElement ||
2030+
(event.clientX <= 0 && event.clientY <= 0) ||
2031+
event.clientX >= window.innerWidth ||
2032+
event.clientY >= window.innerHeight
2033+
) {
2034+
if (this._linkDropZone && !this._linkDropZone.contains(event.relatedTarget)) {
2035+
this._isLinkDragging = false;
2036+
this._hideLinkDropZone();
2037+
}
2038+
}
2039+
}
2040+
2041+
_handleLinkDragDrop(event) {
2042+
if (!this._linkDropZone || !this._linkDropZone.contains(event.target)) {
2043+
if (this._linkDropZone && this._linkDropZone.hasAttribute('enabled')) {
2044+
this._isLinkDragging = false;
2045+
this._hideLinkDropZone(true); // true for forced hiding
2046+
}
2047+
}
2048+
}
2049+
2050+
_handleLinkDragEnd(event) {
2051+
this._isLinkDragging = false;
2052+
this._hideLinkDropZone(true); // true for forced hiding
2053+
}
2054+
2055+
_handleDropForSplit(event) {
2056+
let linkDropZone = this._linkDropZone;
2057+
event.preventDefault();
2058+
event.stopPropagation();
2059+
2060+
const url = this._validateURI(event.dataTransfer);
2061+
2062+
if (!url) {
2063+
this._hideDropZoneAndResetState();
2064+
return;
2065+
}
2066+
2067+
const currentTab = gZenGlanceManager.getTabOrGlanceParent(gBrowser.selectedTab);
2068+
const newTab = this.openAndSwitchToTab(url, { inBackground: false });
2069+
2070+
if (!newTab) {
2071+
this._hideDropZoneAndResetState();
2072+
return;
2073+
}
2074+
2075+
const linkDropSide = this._calculateDropSide(event, linkDropZone);
2076+
2077+
this._createOrUpdateSplitViewWithSide(currentTab, newTab, linkDropSide);
2078+
2079+
this._hideDropZoneAndResetState();
2080+
}
2081+
_calculateDropSide(event, linkDropZone) {
2082+
const rect = linkDropZone.getBoundingClientRect();
2083+
const x = event.clientX - rect.left;
2084+
const y = event.clientY - rect.top;
2085+
const width = rect.width;
2086+
const height = rect.height;
2087+
2088+
const edgeSizeRatio = 0.3; // 30% of the size, maybe increase to 35%
2089+
const hEdge = width * edgeSizeRatio;
2090+
const vEdge = height * edgeSizeRatio;
2091+
2092+
const isInLeftEdge = x < hEdge;
2093+
const isInRightEdge = x > width - hEdge;
2094+
const isInTopEdge = y < vEdge;
2095+
const isInBottomEdge = y > height - vEdge;
2096+
2097+
if (isInTopEdge) {
2098+
if (isInLeftEdge && x / width < y / height) return 'left'; // More left in angle
2099+
if (isInRightEdge && (width - x) / width < y / height) return 'right'; // More right in angle
2100+
return 'top';
2101+
}
2102+
if (isInBottomEdge) {
2103+
if (isInLeftEdge && x / width < (height - y) / height) return 'left';
2104+
if (isInRightEdge && (width - x) / width < (height - y) / height) return 'right';
2105+
return 'bottom';
2106+
}
2107+
if (isInLeftEdge) {
2108+
return 'left';
2109+
}
2110+
if (isInRightEdge) {
2111+
return 'right';
2112+
}
2113+
return 'center';
2114+
}
2115+
2116+
_createOrUpdateSplitViewWithSide(currentTab, newTab, linkDropSide) {
2117+
const SIDES = ['left', 'right', 'top', 'bottom'];
2118+
const groupIndex = this._data.findIndex((group) => group.tabs.includes(currentTab));
2119+
2120+
if (groupIndex > -1) {
2121+
const group = this._data[groupIndex];
2122+
2123+
if (group.tabs.length >= this.MAX_TABS) {
2124+
console.warn(`Cannot add tab to split, MAX_TABS (${this.MAX_TABS}) reached.`);
2125+
return;
2126+
}
2127+
2128+
const splitViewGroup = this._getSplitViewGroup(group.tabs);
2129+
if (splitViewGroup && newTab.group !== splitViewGroup) {
2130+
this._moveTabsToContainer([newTab], currentTab);
2131+
gBrowser.moveTabToGroup(newTab, splitViewGroup);
2132+
}
2133+
2134+
if (!group.tabs.includes(newTab)) {
2135+
group.tabs.push(newTab);
2136+
2137+
const targetNode = this.getSplitNodeFromTab(currentTab);
2138+
const isValidSide = SIDES.includes(linkDropSide);
2139+
2140+
if (targetNode && isValidSide) {
2141+
this.splitIntoNode(targetNode, new SplitLeafNode(newTab, 50), linkDropSide, 0.5);
2142+
} else {
2143+
const parentNode = targetNode?.parent || group.layoutTree;
2144+
this.addTabToSplit(newTab, parentNode, false);
2145+
}
2146+
2147+
this.activateSplitView(group, true);
2148+
}
2149+
return;
2150+
}
2151+
2152+
const splitConfig = {
2153+
left: { tabs: [newTab, currentTab], gridType: 'vsep', initialIndex: 0 },
2154+
right: { tabs: [currentTab, newTab], gridType: 'vsep', initialIndex: 1 },
2155+
top: { tabs: [newTab, currentTab], gridType: 'hsep', initialIndex: 0 },
2156+
bottom: { tabs: [currentTab, newTab], gridType: 'hsep', initialIndex: 1 },
2157+
};
2158+
2159+
const {
2160+
tabs: tabsToSplit,
2161+
gridType,
2162+
initialIndex,
2163+
} = splitConfig[linkDropSide] || {
2164+
// If linkDropSide is invalid should use the default "vsep"
2165+
tabs: [currentTab, newTab],
2166+
gridType: 'vsep',
2167+
initialIndex: 1,
2168+
};
2169+
2170+
this.splitTabs(tabsToSplit, gridType, initialIndex);
2171+
}
2172+
2173+
_hideDropZoneAndResetState() {
2174+
if (this._linkDropZone && this._linkDropZone.hasAttribute('enabled')) {
2175+
this._isLinkDragging = false;
2176+
this._hideLinkDropZone(true);
2177+
}
2178+
}
18972179
}
18982180

18992181
window.gZenViewSplitter = new ZenViewSplitter();

src/zen/split-view/zen-decks.css

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,3 +221,55 @@
221221
transition-delay: 0s;
222222
}
223223
}
224+
225+
#zen-drop-link-zone {
226+
position: fixed;
227+
top: 50%;
228+
left: 50%;
229+
width: 260px;
230+
height: 150px;
231+
232+
background: var(--zen-branding-bg);
233+
border: 1px solid rgba(255, 255, 255, 0.08);
234+
border-radius: 18px;
235+
box-shadow:
236+
rgba(0, 0, 0, 0.45) 0px 15px 35px,
237+
rgba(255, 255, 255, 0.04) 0px 1px 0px inset;
238+
239+
display: flex;
240+
flex-direction: column;
241+
align-items: center;
242+
justify-content: center;
243+
244+
z-index: 999;
245+
opacity: 0;
246+
transform: translate(-50%, -50%) translateY(80px) scale(0.1);
247+
transition:
248+
transform 0.38s cubic-bezier(0.16, 1, 0.3, 1),
249+
opacity 0.38s ease;
250+
pointer-events: none;
251+
252+
&[enabled='true'] {
253+
opacity: 1;
254+
transform: translate(-50%, -50%) translateY(0) scale(1);
255+
pointer-events: auto;
256+
}
257+
258+
&[has-focus='true'] {
259+
transform: translate(-50%, -50%) translateY(0) scale(1.03);
260+
transition:
261+
transform 0.25s cubic-bezier(0.22, 1, 0.36, 1),
262+
border-color 0.25s ease;
263+
border-color: var(--zen-primary-color);
264+
}
265+
266+
& text {
267+
font-size: 14px;
268+
line-height: 1.4;
269+
text-align: center;
270+
color: rgba(255, 255, 255, 0.85);
271+
font-weight: 500;
272+
letter-spacing: 0.2px;
273+
user-select: none;
274+
}
275+
}

0 commit comments

Comments
 (0)