Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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.inc
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ pref('zen.workspaces.debug', true);

// Zen Split View
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);

Expand Down
282 changes: 282 additions & 0 deletions src/zen/split-view/ZenViewSplitter.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ class ZenViewSplitter extends ZenDOMOperatedFeature {

MAX_TABS = 4;

// Link drag and drop
_linkDropZone = null;
_isLinkDragging = false;

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

Expand Down Expand Up @@ -123,6 +127,11 @@ class ZenViewSplitter extends ZenDOMOperatedFeature {
tabBox.addEventListener('dragover', this.onBrowserDragOverToSplit.bind(this));
this.onBrowserDragEndToSplit = this.onBrowserDragEndToSplit.bind(this);
}

// If enabled initialize the link drag and drop
if (Services.prefs.getBoolPref('zen.splitView.enable-link-drop')) {
this.#initLinkDragDropSplit();
}
}

insertIntoContextMenu() {
Expand Down Expand Up @@ -1894,6 +1903,279 @@ class ZenViewSplitter extends ZenDOMOperatedFeature {
}
return true;
}

#initLinkDragDropSplit() {
this._handleLinkDragEnter = this._handleLinkDragEnter.bind(this);
this._handleLinkDragLeave = this._handleLinkDragLeave.bind(this);
this._handleLinkDragDrop = this._handleLinkDragDrop.bind(this);
this._handleLinkDragEnd = this._handleLinkDragEnd.bind(this);

const tabBox = document.getElementById('tabbrowser-tabbox');

tabBox.addEventListener('dragenter', this._handleLinkDragEnter, true);
tabBox.addEventListener('dragleave', this._handleLinkDragLeave, false);
tabBox.addEventListener('drop', this._handleLinkDragDrop, false);
tabBox.addEventListener('dragend', this._handleLinkDragEnd, false);
}

_createLinkDropZone() {
if (this._linkDropZone) return;

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('value', 'Drop link to split'); // Localization! data-l10n-id

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

this._linkDropZone.addEventListener('dragover', (event) => {
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = 'link';
if (!this._linkDropZone.hasAttribute('has-focus')) {
this._linkDropZone.setAttribute('has-focus', 'true');
}
});

this._linkDropZone.addEventListener('dragleave', (event) => {
event.stopPropagation();
if (!this._linkDropZone.contains(event.relatedTarget)) {
this._linkDropZone.removeAttribute('has-focus');
}
});

this._linkDropZone.addEventListener('drop', this._handleDropForSplit.bind(this));

const tabBox = document.getElementById('tabbrowser-tabbox');
tabBox.appendChild(this._linkDropZone);
}

_showLinkDropZone() {
if (!this._linkDropZone) this._createLinkDropZone();

this._linkDropZone.setAttribute('enabled', 'true');
}

_hideLinkDropZone(force = false) {
if (!this._linkDropZone || !this._linkDropZone.hasAttribute('enabled')) return;

if (this._isLinkDragging && !force) return;

this._linkDropZone.removeAttribute('enabled');
this._linkDropZone.removeAttribute('has-focus');
}

_validateURI(dataTransfer) {
let dt = dataTransfer;

const URL_TYPES = ['text/uri-list', 'text/x-moz-url', 'text/plain'];

const FIXUP_FLAGS = Ci.nsIURIFixup.FIXUP_FLAG_FIX_SCHEME_TYPOS;

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

const info = Services.uriFixup.getFixupURIInfo(uriString, FIXUP_FLAGS);

if (!info || !info.fixedURI) {
return null;
}

return info.fixedURI.spec;
}

_handleLinkDragEnter(event) {
// If rearrangeViewEnabled - don't do anything
if (this.rearrangeViewEnabled) {
return;
}

const shouldBeDisabled = !this.canOpenLinkInSplitView();
if (shouldBeDisabled) return;

// If the target is our drop zone or one of its children, or already active, do nothing here.
if (
this._linkDropZone &&
(this._linkDropZone.contains(event.target) || this._linkDropZone.hasAttribute('enabled'))
) {
return;
}

// If the data is not a valid URI, we don't want to do anything
if (!this._validateURI(event.dataTransfer)) {
return;
}

this._isLinkDragging = true;
this._showLinkDropZone();

event.preventDefault();
event.stopPropagation();
}

_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._isLinkDragging = false;
this._hideLinkDropZone();
}
}
}

_handleLinkDragDrop(event) {
if (!this._linkDropZone || !this._linkDropZone.contains(event.target)) {
if (this._linkDropZone && this._linkDropZone.hasAttribute('enabled')) {
this._isLinkDragging = false;
this._hideLinkDropZone(true); // true for forced hiding
}
}
}

_handleLinkDragEnd(event) {
this._isLinkDragging = false;
this._hideLinkDropZone(true); // true for forced hiding
}

_handleDropForSplit(event) {
let linkDropZone = this._linkDropZone;
event.preventDefault();
event.stopPropagation();

const url = this._validateURI(event.dataTransfer);

if (!url) {
this._hideDropZoneAndResetState();
return;
}

const currentTab = gZenGlanceManager.getTabOrGlanceParent(gBrowser.selectedTab);
const newTab = this.openAndSwitchToTab(url, { inBackground: false });

if (!newTab) {
this._hideDropZoneAndResetState();
return;
}

const linkDropSide = this._calculateDropSide(event, linkDropZone);

this._createOrUpdateSplitViewWithSide(currentTab, newTab, linkDropSide);

this._hideDropZoneAndResetState();
}
_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;

const edgeSizeRatio = 0.3; // 30% of the size, maybe increase to 35%
const hEdge = width * edgeSizeRatio;
const vEdge = height * edgeSizeRatio;

const isInLeftEdge = x < hEdge;
const isInRightEdge = x > width - hEdge;
const isInTopEdge = y < vEdge;
const isInBottomEdge = y > height - vEdge;

if (isInTopEdge) {
if (isInLeftEdge && x / width < y / height) return 'left'; // More left in angle
if (isInRightEdge && (width - x) / width < y / height) return 'right'; // More right in angle
return 'top';
}
if (isInBottomEdge) {
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';
}
return 'center';
}

_createOrUpdateSplitViewWithSide(currentTab, newTab, linkDropSide) {
const SIDES = ['left', 'right', 'top', 'bottom'];
const groupIndex = this._data.findIndex((group) => group.tabs.includes(currentTab));

if (groupIndex > -1) {
const group = this._data[groupIndex];

if (group.tabs.length >= this.MAX_TABS) {
console.warn(`Cannot add tab to split, MAX_TABS (${this.MAX_TABS}) reached.`);
return;
}

const splitViewGroup = this._getSplitViewGroup(group.tabs);
if (splitViewGroup && newTab.group !== splitViewGroup) {
this._moveTabsToContainer([newTab], currentTab);
gBrowser.moveTabToGroup(newTab, splitViewGroup);
}

if (!group.tabs.includes(newTab)) {
group.tabs.push(newTab);

const targetNode = this.getSplitNodeFromTab(currentTab);
const isValidSide = SIDES.includes(linkDropSide);

if (targetNode && isValidSide) {
this.splitIntoNode(targetNode, new SplitLeafNode(newTab, 50), linkDropSide, 0.5);
} else {
const parentNode = targetNode?.parent || group.layoutTree;
this.addTabToSplit(newTab, parentNode, false);
}

this.activateSplitView(group, true);
}
return;
}

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 {
tabs: tabsToSplit,
gridType,
initialIndex,
} = splitConfig[linkDropSide] || {
// If linkDropSide is invalid should use the default "vsep"
tabs: [currentTab, newTab],
gridType: 'vsep',
initialIndex: 1,
};

this.splitTabs(tabsToSplit, gridType, initialIndex);
}

_hideDropZoneAndResetState() {
if (this._linkDropZone && this._linkDropZone.hasAttribute('enabled')) {
this._isLinkDragging = false;
this._hideLinkDropZone(true);
}
}
}

window.gZenViewSplitter = new ZenViewSplitter();
52 changes: 52 additions & 0 deletions src/zen/split-view/zen-decks.css
Original file line number Diff line number Diff line change
Expand Up @@ -221,3 +221,55 @@
transition-delay: 0s;
}
}

#zen-drop-link-zone {
position: fixed;
top: 50%;
left: 50%;
width: 260px;
height: 150px;

background: var(--zen-branding-bg);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 18px;
box-shadow:
rgba(0, 0, 0, 0.45) 0px 15px 35px,
rgba(255, 255, 255, 0.04) 0px 1px 0px inset;

display: flex;
flex-direction: column;
align-items: center;
justify-content: center;

z-index: 999;
opacity: 0;
transform: translate(-50%, -50%) translateY(80px) scale(0.1);
transition:
transform 0.38s cubic-bezier(0.16, 1, 0.3, 1),
opacity 0.38s ease;
pointer-events: none;

&[enabled='true'] {
opacity: 1;
transform: translate(-50%, -50%) translateY(0) scale(1);
pointer-events: auto;
}

&[has-focus='true'] {
transform: translate(-50%, -50%) translateY(0) scale(1.03);
transition:
transform 0.25s cubic-bezier(0.22, 1, 0.36, 1),
border-color 0.25s ease;
border-color: var(--zen-primary-color);
}

& text {
font-size: 14px;
line-height: 1.4;
text-align: center;
color: rgba(255, 255, 255, 0.85);
font-weight: 500;
letter-spacing: 0.2px;
user-select: none;
}
}