Skip to content

feat: Drag and Drop link to split #8773

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 25 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
81ec621
feat: Drag and Drop link to split
octaviusz Jun 1, 2025
9cc475f
Moved all the logic to class ZenSplitViewLinkDrop
octaviusz Jun 1, 2025
d8910be
npm run pretty
octaviusz Jun 1, 2025
fa141a5
Removed optional if statement
octaviusz Jun 1, 2025
8899251
Merge branch 'dev' into drop-link-to-split
mr-cheffy Jun 6, 2025
43712aa
Move pref to the correct feature config file
mr-cheffy Jun 6, 2025
112dee9
Merge branch 'dev' into drop-link-to-split
mr-cheffy Jun 6, 2025
1620aa1
Add l10n for split view link drop zone
octaviusz Jun 6, 2025
46ce1ae
Reworked the logic instead of hiding now removing
octaviusz Jun 6, 2025
1e5254c
Fix in some cases `uriFixup` throws an exception
octaviusz Jun 7, 2025
66ec8e4
Fix opening tab for left and top sides
octaviusz Jun 7, 2025
402dc33
Merge branch 'dev' into drop-link-to-split
octaviusz Jun 15, 2025
df3b4bb
Add auto close after timeout
octaviusz Jun 15, 2025
1e57258
Refactor linkDropZone events
octaviusz Jun 15, 2025
0d50fe7
Refactor _createOrUpdateSplitViewWithSide
octaviusz Jun 15, 2025
6828930
Add animation to link drop zone
octaviusz Jun 16, 2025
2b157a0
Add node size alignment
octaviusz Jun 17, 2025
95fa03b
Merge branch 'dev' into drop-link-to-split
octaviusz Jun 28, 2025
609f6ca
Fix SplitLeafNode -> nsSplitLeafNode
octaviusz Jun 28, 2025
ca428b4
Merge branch 'dev' into drop-link-to-split
octaviusz Jul 9, 2025
1f9c58e
feat: Enhance drag and drop zone visuals and animations
octaviusz Jul 10, 2025
b4e45ec
feat: Enhance link drop zone with dynamic icon and glance functionality
octaviusz Jul 10, 2025
6e6960f
test: add browser tests for link darg and drop behavior
octaviusz Jul 11, 2025
9524d8a
fix: npm run pretty
octaviusz Jul 11, 2025
1ac0953
Merge branch 'dev' into drop-link-to-split
mr-cheffy Jul 11, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/browser/app/profile/features/split-view.inc
Original file line number Diff line number Diff line change
Expand Up @@ -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);
315 changes: 315 additions & 0 deletions src/zen/split-view/ZenViewSplitter.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,314 @@ class nsSplitNode extends nsSplitLeafNode {
}
}

class ZenSplitViewLinkDrop {
#zenViewSplitter;
_linkDropZone = null;
_lastSplitSide = 'right';

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() {
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');

const text = document.createXULElement('description');
text.setAttribute('data-l10n-id', 'zen-drop-link-zone-label');

content.appendChild(text);
this._linkDropZone.appendChild(content);

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(this._linkDropZone);

gZenUIManager.motion.animate(this._linkDropZone, {
opacity: [0, 1],
x: ['-50%', '-50%'],
y: ['-40%', '-50%'],
scale: [0.1, 1],
duration: 0.15,
ease: [0.16, 1, 0.3, 1],
});
}
_handleDragOver(event) {
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = 'link';
const side = this._calculateDropSide(event, this._linkDropZone);
this._linkDropZone.setAttribute('drop-side', side);

if (!this._linkDropZone.hasAttribute('has-focus')) {
this._linkDropZone.setAttribute('has-focus', 'true');
}
}

_handleDragLeave(event) {
event.stopPropagation();
if (!this._linkDropZone.contains(event.relatedTarget)) {
this._linkDropZone.removeAttribute('drop-side');
this._linkDropZone.removeAttribute('has-focus');
}
}
_removeLinkDropZone() {
if (!this._linkDropZone) return;

gZenUIManager.motion
.animate(this._linkDropZone, {
opacity: [1, 0],
x: ['-50%', '-50%'],
y: ['-40%', '-50%'],
scale: [1, 0.1],
duration: 0.15,
ease: [0.16, 1, 0.3, 1],
})
.then(() => {
this._linkDropZone.remove();
this._linkDropZone = 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);

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;
});
} else {
// If linkDropSide is center, then open a new tab at the start/end
const shouldPrepend = ['left', 'top'].includes(this._lastSplitSide);
this.#zenViewSplitter.addTabToSplit(newTab, parentNode, shouldPrepend);
}

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 = [];
Expand All @@ -79,6 +387,8 @@ class nsZenViewSplitter extends ZenDOMOperatedFeature {

MAX_TABS = 4;

#ZenSplitViewLinkDrop;

init() {
this.handleTabEvent = this._handleTabEvent.bind(this);

Expand Down Expand Up @@ -123,6 +433,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() {
Expand Down
Loading