// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
/* eslint-disable consistent-return */
import { nsZenDOMOperatedFeature } from "chrome://browser/content/zen-components/ZenCommonUtils.mjs";
class nsSplitLeafNode {
/**
* The percentage of the size of the parent the node takes up, dependent on parent direction this is either
* width or height.
*
* @type {number}
*/
sizeInParent;
/**
* @type {object}
*/
positionToRoot; // position relative to root node
/**
* @type {nsSplitNode}
*/
parent;
constructor(tab, sizeInParent) {
this.tab = tab;
this.sizeInParent = sizeInParent;
}
get heightInParent() {
return this.parent.direction === "column" ? this.sizeInParent : 100;
}
get widthInParent() {
return this.parent.direction === "row" ? this.sizeInParent : 100;
}
}
class nsSplitNode extends nsSplitLeafNode {
/**
* @type {string}
*/
direction;
_children = [];
constructor(direction, sizeInParent) {
super(null, sizeInParent);
this.sizeInParent = sizeInParent;
this.direction = direction; // row or column
}
set children(children) {
if (children) {
children.forEach(c => (c.parent = this));
}
this._children = children;
}
get children() {
return this._children;
}
addChild(child, prepend = true) {
child.parent = this;
if (prepend) {
this._children.unshift(child);
} else {
this._children.push(child);
}
}
}
class nsZenViewSplitter extends nsZenDOMOperatedFeature {
currentView = -1;
_data = [];
_tabBrowserPanel = null;
__hasSetMenuListener = false;
overlay = null;
_splitNodeToSplitters = new Map();
_tabToSplitNode = new Map();
dropZone;
_edgeHoverSize;
minResizeWidth;
_lastOpenedTab = null;
MAX_TABS = 4;
init() {
this.handleTabEvent = this._handleTabEvent.bind(this);
XPCOMUtils.defineLazyPreferenceGetter(
this,
"minResizeWidth",
"zen.splitView.min-resize-width",
7
);
XPCOMUtils.defineLazyPreferenceGetter(
this,
"_edgeHoverSize",
"zen.splitView.rearrange-edge-hover-size",
24
);
ChromeUtils.defineLazyGetter(this, "overlay", () =>
document.getElementById("zen-splitview-overlay")
);
ChromeUtils.defineLazyGetter(this, "dropZone", () =>
document.getElementById("zen-splitview-dropzone")
);
window.addEventListener("TabClose", this.handleTabClose.bind(this));
window.addEventListener(
"TabBrowserDiscarded",
this.handleTabBrowserDiscarded.bind(this)
);
window.addEventListener("TabSelect", this.onTabSelect.bind(this));
this.initializeContextMenu();
this.insertIntoContextMenu();
window.addEventListener(
"AfterWorkspacesSessionRestore",
this.onAfterWorkspaceSessionRestore.bind(this),
{
once: true,
}
);
// Add drag over listener to the browser view
if (Services.prefs.getBoolPref("zen.splitView.enable-tab-drop")) {
const tabBox = document.getElementById("tabbrowser-tabbox");
tabBox.addEventListener(
"dragover",
this.onBrowserDragOverToSplit.bind(this)
);
this.onBrowserDragEndToSplit = this.onBrowserDragEndToSplit.bind(this);
}
}
insertIntoContextMenu() {
const sibling = document.getElementById("context-sep-open");
const menuitem = document.createXULElement("menuitem");
menuitem.setAttribute("id", "context-zenSplitLink");
menuitem.setAttribute("hidden", "true");
menuitem.setAttribute("command", "cmd_zenSplitViewLinkInNewTab");
menuitem.setAttribute("data-l10n-id", "zen-split-link");
sibling.insertAdjacentElement("beforebegin", menuitem);
}
/**
* @param {Event} event - The event that triggered the tab close.
* @description Handles the tab close event.7
*/
handleTabClose(event) {
const tab = event.target;
if (tab === this._lastOpenedTab) {
this._lastOpenedTab = null;
}
const groupIndex = this._data.findIndex(group => group.tabs.includes(tab));
if (groupIndex < 0) {
return;
}
this.removeTabFromGroup(tab, groupIndex, { forUnsplit: true });
}
/**
* @param {Event} event - The event that triggered the tab browser discard.
* @description Handles the tab browser discard event.
*/
async handleTabBrowserDiscarded(event) {
const tab = event.target;
if (tab.group?.hasAttribute("split-view-group")) {
gBrowser.explicitUnloadTabs(tab.group.tabs);
for (const t of tab.group.tabs) {
if (t.glanceTab) {
gBrowser.explicitUnloadTabs([t.glanceTab]);
}
}
}
}
/**
* @param {Event} event - The event that triggered the tab select.
* @description Handles the tab select event.
* @returns {void}
*/
onTabSelect(event) {
const previousTab = event.detail.previousTab;
if (previousTab === gBrowser.selectedTab && this._canDrop) {
return;
}
if (previousTab && !previousTab.hasAttribute("zen-empty-tab")) {
this._lastOpenedTab = previousTab;
}
this.onLocationChange(event.target.linkedBrowser);
}
/**
* Removes a tab from a group.
*
* @param {Tab} tab - The tab to remove.
* @param {number} [groupIndex=undefined] - The index of the group.
* @param {object} [options={}] - Additional options.
* @param {boolean} [options.forUnsplit=false] - Whether the removal is for unsplitting.
* @param {boolean} [options.dontRebuildGrid=false] - Whether to skip rebuilding the grid layout.
* @param {boolean} [options.changeTab=true] - Whether to change the selected tab.
*/
removeTabFromGroup(
tab,
groupIndex = undefined,
{ forUnsplit = false, dontRebuildGrid = false, changeTab = true } = {}
) {
if (typeof groupIndex === "undefined") {
groupIndex = this._data.findIndex(group => group.tabs.includes(tab));
}
// If groupIndex === -1, so `this._data.findIndex` couldn't find the split group
if (groupIndex < 0) {
return;
}
const group = this._data[groupIndex];
const tabIndex = group.tabs.indexOf(tab);
if (group.tabs.length < 3) {
// We need to remove all remaining tabs from the group when unsplitting
let remainingTabs = [...group.tabs]; // Copy array since we'll modify it
if (!dontRebuildGrid) {
for (let remainingTab of remainingTabs) {
this.resetTabState(remainingTab, forUnsplit);
}
}
this.removeGroup(groupIndex);
if (changeTab) {
gBrowser.selectedTab = remainingTabs[remainingTabs.length - 1];
document
.getElementById("cmd_zenNewEmptySplit")
.removeAttribute("disabled");
}
} else {
group.tabs.splice(tabIndex, 1);
this.resetTabState(tab, forUnsplit);
if (tab.group && tab.group.hasAttribute("split-view-group")) {
gBrowser.ungroupTab(tab);
this.#dispatchItemEvent("ZenTabRemovedFromSplit", tab);
}
const node = this.getSplitNodeFromTab(tab);
const toUpdate = this.removeNode(node);
this.applyGridLayout(toUpdate);
// Select next tab if the removed tab was selected
if (gBrowser.selectedTab === tab) {
gBrowser.selectedTab = group.tabs[0];
}
}
}
#getDragImageForSplit(tab) {
const element = window.MozXULElement.parseXULToFragment(
`
`
).querySelector("#zen-split-view-drag-image");
const image = element.querySelector("image");
const label = element.querySelector("label");
image.src = tab.getAttribute("image");
label.textContent = tab.label;
document.documentElement.appendChild(element);
this._dndElement = element;
return element;
}
_calculateDropSide(event, panelsRect) {
const { width, height } = panelsRect;
const { clientX, clientY } = event;
// TODO(octaviusz): Maybe we should add this as preference
// `zen.splitView.tab-drop-treshold`
const quarterWidth = width / 4;
const quarterHeight = height / 4;
const edges = [
{
side: "left",
dist: clientX - panelsRect.left,
threshold: quarterWidth,
},
{
side: "right",
dist: panelsRect.right - clientX,
threshold: quarterWidth,
},
{ side: "top", dist: clientY - panelsRect.top, threshold: quarterHeight },
{
side: "bottom",
dist: panelsRect.bottom - clientY,
threshold: quarterHeight,
},
];
let closestEdge = null;
let minDist = Infinity;
for (const edge of edges) {
if (edge.dist < edge.threshold && edge.dist < minDist) {
minDist = edge.dist;
closestEdge = edge;
}
}
return closestEdge ? closestEdge.side : null;
}
// eslint-disable-next-line complexity
onBrowserDragOverToSplit(event) {
gBrowser.tabContainer.tabDragAndDrop.clearSpaceSwitchTimer();
if (this.fakeBrowser) {
return;
}
var dt = event.dataTransfer;
var draggedTab;
if (dt.mozTypesAt(0)[0] == TAB_DROP_TYPE) {
// tab copy or move
draggedTab = dt.mozGetDataAt(TAB_DROP_TYPE, 0);
// not our drop then
if (
!gBrowser.isTab(draggedTab) ||
gBrowser.selectedTab.hasAttribute("zen-empty-tab") ||
draggedTab.ownerGlobal !== window
) {
return;
}
gBrowser.tabContainer.tabDragAndDrop.finishMoveTogetherSelectedTabs(
draggedTab
);
} else {
return;
}
if (
!this._lastOpenedTab ||
(this._lastOpenedTab.getAttribute("zen-workspace-id") !==
draggedTab.getAttribute("zen-workspace-id") &&
!this._lastOpenedTab.hasAttribute("zen-essential") &&
!draggedTab.hasAttribute("zen-essential")) ||
this._lastOpenedTab.hasAttribute("zen-live-folder-item-id")
) {
this._lastOpenedTab = gBrowser.selectedTab;
}
if (!draggedTab || this._canDrop || this._hasAnimated || this.fakeBrowser) {
return;
}
if (
draggedTab.splitView ||
draggedTab.hasAttribute("zen-live-folder-item-id")
) {
return;
}
const currentView = this._data[this._lastOpenedTab.splitViewValue];
if (currentView?.tabs.length >= this.MAX_TABS) {
return;
}
const panelsRect = gBrowser.tabbox.getBoundingClientRect();
const panelsWidth = panelsRect.width;
const panelsHeight = panelsRect.height;
if (
event.clientX > panelsRect.left + panelsWidth - 10 ||
event.clientX < panelsRect.left + 10 ||
event.clientY < panelsRect.top + 10 ||
event.clientY > panelsRect.bottom - 10
) {
return;
}
const dropSide = this._calculateDropSide(event, panelsRect);
if (!dropSide) {
return;
}
// first quarter or last quarter of the screen, but not the middle
if (!(
event.clientX < panelsRect.left + panelsWidth / 4 ||
event.clientX > panelsRect.left + (panelsWidth / 4) * 3 ||
event.clientY < panelsRect.top + panelsHeight / 4 ||
event.clientY > panelsRect.top + (panelsHeight / 4) * 3
)) {
return;
}
dt.mozCursor = "default";
if (!this._dndElement) {
const originalDNDArgs =
gBrowser.tabContainer.tabDragAndDrop.originalDragImageArgs;
requestAnimationFrame(() => {
dt.updateDragImage(
this.#getDragImageForSplit(draggedTab),
originalDNDArgs[1],
originalDNDArgs[2]
);
});
gBrowser.tabContainer.tabDragAndDrop.clearDragOverVisuals();
}
const oldTab = this._lastOpenedTab;
this._canDrop = true;
// eslint-disable-next-line mozilla/valid-services
Services.zen.playHapticFeedback();
this._draggingTab = draggedTab;
gBrowser.selectedTab = oldTab;
this._hasAnimated = true;
this.tabBrowserPanel.setAttribute("dragging-split", "true");
this._animateDropEdge(dropSide, currentView, draggedTab, oldTab);
}
_animateDropEdge(dropSide, currentView, draggedTab, oldTab) {
// Add a min width to all the browser elements to prevent them from resizing
// eslint-disable-next-line no-shadow
const { height, width } = gBrowser.tabbox.getBoundingClientRect();
let numOfTabsToDivide = 2;
if (currentView) {
numOfTabsToDivide = currentView.tabs.length + 1;
}
const halfWidth = width / numOfTabsToDivide;
const halfHeight = height / numOfTabsToDivide;
const side = dropSide;
for (const browser of gBrowser.browsers) {
if (!browser) {
continue;
}
const { width: browserWidth, height: browserHeight } =
browser.getBoundingClientRect();
// Only apply it to the left side because if we add it to the right side,
// we wont be able to move the element to the left.
// FIXME: This is a workaround, we should find a better way to do this
switch (side) {
case "left":
browser.style.minWidth = `${browserWidth}px`;
break;
case "top":
browser.style.minHeight = `${browserHeight}px`;
break;
}
}
this.fakeBrowser = document.createXULElement("vbox");
window.addEventListener("dragend", this.onBrowserDragEndToSplit, {
once: true,
});
const padding = ZenThemeModifier.elementSeparation;
this.fakeBrowser.setAttribute("flex", "1");
this.fakeBrowser.id = "zen-split-view-fake-browser";
if (oldTab.splitView) {
this.fakeBrowser.setAttribute("has-split-view", "true");
}
gBrowser.tabbox.appendChild(this.fakeBrowser);
this.fakeBrowser.setAttribute("side", side);
let animateTabBox = null;
let animateFakeBrowser = null;
switch (side) {
case "left":
animateTabBox = {
padding: [0, `0 0 0 ${halfWidth}px`],
};
animateFakeBrowser = {
width: [0, `${halfWidth - padding}px`],
margin: [0, `0 0 0 ${-halfWidth}px`],
};
break;
case "right":
animateTabBox = {
padding: [0, `0 ${halfWidth}px 0 0`],
};
animateFakeBrowser = {
width: [0, `${halfWidth - padding}px`],
};
break;
case "top":
animateTabBox = {
padding: [0, `${halfHeight}px 0 0 0`],
};
animateFakeBrowser = {
height: [0, `${halfHeight - padding}px`],
margin: [0, `${-halfHeight}px 0 0 0`],
};
break;
case "bottom":
animateTabBox = {
padding: [0, `0 0 ${halfHeight}px 0`],
};
animateFakeBrowser = {
height: [0, `${halfHeight - padding}px`],
};
break;
}
this._finishAllAnimatingPromise = Promise.all([
gZenUIManager.motion.animate(gBrowser.tabbox, animateTabBox, {
duration: 0.1,
easing: "ease-out",
}),
gZenUIManager.motion.animate(this.fakeBrowser, animateFakeBrowser, {
duration: 0.1,
easing: "ease-out",
}),
]);
if (this._finishAllAnimatingPromise) {
this._finishAllAnimatingPromise.then(() => {
if (draggedTab !== oldTab) {
draggedTab.linkedBrowser.docShellIsActive = false;
draggedTab.linkedBrowser
.closest(".browserSidebarContainer")
.classList.remove("deck-selected");
}
this.fakeBrowser.addEventListener(
"dragleave",
this.onBrowserDragEndToSplit
);
this._canDrop = true;
});
}
}
onBrowserDragEndToSplit(event, cancelled = false) {
if (!this._canDrop) {
return;
}
const panelsRect = gBrowser.tabbox.getBoundingClientRect();
const fakeBrowserRect =
this.fakeBrowser && this.fakeBrowser.getBoundingClientRect();
if (
((fakeBrowserRect &&
event.clientX > fakeBrowserRect.left &&
event.clientX < fakeBrowserRect.left + fakeBrowserRect.width &&
event.clientY > fakeBrowserRect.top &&
event.clientY < fakeBrowserRect.top + fakeBrowserRect.height) ||
(event.screenX === 0 && event.screenY === 0)) && // It's equivalent to 0 if the event has been dropped
!cancelled
) {
return;
}
if (!this._hasAnimated || !this.fakeBrowser) {
return;
}
const panelsWidth = panelsRect.width;
const panelsHeight = panelsRect.height;
let numOfTabsToDivide = 2;
const currentView = this._data[this._lastOpenedTab.splitViewValue];
if (currentView) {
numOfTabsToDivide = currentView.tabs.length + 1;
}
const halfWidth = panelsWidth / numOfTabsToDivide;
const halfHeight = panelsHeight / numOfTabsToDivide;
const padding = ZenThemeModifier.elementSeparation;
if (!this.fakeBrowser) {
return;
}
const side = this.fakeBrowser.getAttribute("side");
this._lastOpenedTab = gBrowser.selectedTab;
this._draggingTab = null;
event.dataTransfer.updateDragImage(
...gBrowser.tabContainer.tabDragAndDrop.originalDragImageArgs
);
this._canDrop = false;
let animateTabBox = null;
let animateFakeBrowser = null;
switch (side) {
case "left":
animateTabBox = {
padding: [`0 0 0 ${halfWidth}px`, 0],
};
animateFakeBrowser = {
width: [`${halfWidth - padding}px`, 0],
margin: [`0 0 0 ${-halfWidth}px`, 0],
};
break;
case "right":
animateTabBox = {
padding: [`0 ${halfWidth}px 0 0`, 0],
};
animateFakeBrowser = {
width: [`${halfWidth - padding}px`, 0],
};
break;
case "top":
animateTabBox = {
padding: [`${halfHeight}px 0 0 0`, 0],
};
animateFakeBrowser = {
height: [`${halfHeight - padding}px`, 0],
margin: [`${-halfHeight}px 0 0 0`, 0],
};
break;
case "bottom":
animateTabBox = {
padding: [`0 0 ${halfHeight}px 0`, 0],
};
animateFakeBrowser = {
height: [`${halfHeight - padding}px`, 0],
};
break;
}
this._finishAllAnimatingPromise = Promise.all([
gZenUIManager.motion.animate(gBrowser.tabbox, animateTabBox, {
duration: 0.1,
easing: "ease-out",
}),
gZenUIManager.motion.animate(this.fakeBrowser, animateFakeBrowser, {
duration: 0.1,
easing: "ease-out",
}),
]);
if (this._finishAllAnimatingPromise) {
this._finishAllAnimatingPromise.then(() => {
this._maybeRemoveFakeBrowser();
});
}
}
/**
* Remove a nsSplitNode from its tree and the view
*
* @param {nsSplitNode} toRemove
* @returns {nsSplitNode} that has to be updated
*/
removeNode(toRemove) {
this._removeNodeSplitters(toRemove, true);
// eslint-disable-next-line no-shadow
const parent = toRemove.parent;
const childIndex = parent.children.indexOf(toRemove);
parent.children.splice(childIndex, 1);
if (parent.children.length !== 1) {
const otherNodeIncrease = 100 / (100 - toRemove.sizeInParent);
parent.children.forEach(c => (c.sizeInParent *= otherNodeIncrease));
return parent;
}
// node that is not a leaf cannot have less than 2 children, this makes for better resizing
// node takes place of parent
const leftOverChild = parent.children[0];
leftOverChild.sizeInParent = parent.sizeInParent;
if (parent.parent) {
const idx = parent.parent.children.indexOf(parent);
if (parent.parent.direction !== leftOverChild.direction) {
leftOverChild.parent = parent.parent;
parent.parent.children[idx] = leftOverChild;
} else {
// node cannot have same direction as it's parent
leftOverChild.children.forEach(c => {
c.sizeInParent *= leftOverChild.sizeInParent / 100;
c.parent = parent.parent;
});
parent.parent.children.splice(idx, 1, ...leftOverChild.children);
this._removeNodeSplitters(leftOverChild, false);
}
this._removeNodeSplitters(parent, false);
return parent.parent;
}
const viewData = Object.values(this._data).find(
s => s.layoutTree === parent
);
viewData.layoutTree = leftOverChild;
leftOverChild.positionToRoot = null;
leftOverChild.parent = null;
return leftOverChild;
}
/**
* @param {object} node
* @param {boolean} recursive
* @private
*/
_removeNodeSplitters(node, recursive) {
this.getSplitters(node)?.forEach(s => s.remove());
this._splitNodeToSplitters.delete(node);
if (!recursive) {
return;
}
if (node && node.children) {
node.children.forEach(c => this._removeNodeSplitters(c));
}
}
toggleWrapperDisplay(value) {
const wrapper = this.overlay?.parentNode;
if (!wrapper) {
return;
}
if (!value) {
wrapper.setAttribute("hidden", "true");
} else {
wrapper.removeAttribute("hidden");
}
}
enableTabRearrangeView(tabDrag = false) {
if (this.rearrangeViewEnabled) {
return;
}
this.rearrangeViewEnabled = true;
this.rearrangeViewView = this.currentView;
if (!this._thumnailCanvas) {
this._thumnailCanvas = document.createElement("canvas");
this._thumnailCanvas.width = 280 * devicePixelRatio;
this._thumnailCanvas.height = 140 * devicePixelRatio;
}
const browsers = this._data[this.currentView].tabs.map(
t => t.linkedBrowser
);
browsers.forEach(b => {
b.style.pointerEvents = "none";
b.style.opacity = ".85";
});
if (!tabDrag) {
this.tabBrowserPanel.addEventListener(
"dragstart",
this.onBrowserDragStart
);
this.tabBrowserPanel.addEventListener("dragend", this.onBrowserDragEnd);
}
this.tabBrowserPanel.addEventListener("dragover", this.onBrowserDragOver);
this.tabBrowserPanel.addEventListener("drop", this.onBrowserDrop);
this.tabBrowserPanel.addEventListener(
"click",
this.disableTabRearrangeView
);
window.addEventListener("keydown", this.disableTabRearrangeView);
}
disableTabRearrangeView = (event = null) => {
if (!this.rearrangeViewEnabled) {
return;
}
if (event) {
// Click or "ESC" key
if (
(event.type === "click" && event.button !== 0) ||
(event.type === "keydown" && event.key !== "Escape")
) {
return;
}
}
if (
!this.rearrangeViewEnabled ||
(event && event.target.classList.contains("zen-split-view-splitter"))
) {
return;
}
this.tabBrowserPanel.removeEventListener(
"dragstart",
this.onBrowserDragStart
);
this.tabBrowserPanel.removeEventListener(
"dragover",
this.onBrowserDragOver
);
this.tabBrowserPanel.removeEventListener("drop", this.onBrowserDrop);
this.tabBrowserPanel.removeEventListener(
"click",
this.disableTabRearrangeView
);
window.removeEventListener("keydown", this.disableTabRearrangeView);
const browsers = this._data[this.rearrangeViewView].tabs.map(
t => t.linkedBrowser
);
browsers.forEach(b => {
b.style.pointerEvents = "";
b.style.opacity = "";
});
this.rearrangeViewEnabled = false;
this.rearrangeViewView = null;
};
onBrowserDragStart = event => {
if (!this.splitViewActive) {
return;
}
let browser;
let isSplitHeaderDrag = false;
const container = event.target.closest(
".browserSidebarContainer[zen-split]"
);
if (container && event.target.closest(".zen-tab-rearrange-button")) {
// Split tab header drag case
const containerRect = container.getBoundingClientRect();
const clickX = event.clientX - containerRect.left;
// Only allow drag if click is NOT in right 20px (close button area)
if (clickX > containerRect.width - 22) {
return;
}
browser = container.querySelector("browser");
isSplitHeaderDrag = true;
} else {
// Regular browser drag case
browser = event.target.querySelector("browser");
}
if (!browser) {
return;
}
const tab = gBrowser.getTabForBrowser(browser);
if (!tab) {
return;
}
// Store the necessary state for drag end
this._dragState = {
tab,
browser,
isSplitHeaderDrag,
};
if (isSplitHeaderDrag) {
this.enableTabRearrangeView(true);
}
browser.style.opacity = ".2";
event.dataTransfer.setData(
"text/plain",
browser.closest(".browserSidebarContainer").id
);
this._draggingTab = tab;
// Canvas setup for drag image
let scale = window.devicePixelRatio;
let canvas = this._dndCanvas;
if (!canvas) {
this._dndCanvas = canvas = document.createElementNS(
"http://www.w3.org/1999/xhtml",
"canvas"
);
canvas.style.width = "100%";
canvas.style.height = "100%";
}
canvas.width = 160 * scale;
canvas.height = 90 * scale;
let toDrag = canvas;
let dragImageOffset = -16;
if (gMultiProcessBrowser) {
var context = canvas.getContext("2d");
context.fillStyle = "white";
context.fillRect(0, 0, canvas.width, canvas.height);
let captureListener;
let platform = AppConstants.platform;
// On Windows and Mac we can update the drag image during a drag
// using updateDragImage. On Linux, we can use a panel.
if (platform === "win" || platform === "macosx") {
captureListener = () => {
event.dataTransfer.updateDragImage(
canvas,
dragImageOffset,
dragImageOffset
);
};
} else {
// Create a panel to use it in setDragImage
// which will tell xul to render a panel that follows
// the pointer while a dnd session is on.
if (!this._dndPanel) {
this._dndCanvas = canvas;
this._dndPanel = document.createXULElement("panel");
this._dndPanel.className = "dragfeedback-tab";
this._dndPanel.setAttribute("type", "drag");
let wrapper = document.createElementNS(
"http://www.w3.org/1999/xhtml",
"div"
);
wrapper.style.width = "160px";
wrapper.style.height = "90px";
wrapper.appendChild(canvas);
this._dndPanel.appendChild(wrapper);
document.documentElement.appendChild(this._dndPanel);
}
toDrag = this._dndPanel;
}
// PageThumb is async with e10s but that's fine
// since we can update the image during the dnd.
PageThumbs.captureToCanvas(browser, canvas)
.then(captureListener)
.catch(e => console.error(e));
} else {
// For the non e10s case we can just use PageThumbs
// sync, so let's use the canvas for setDragImage.
PageThumbs.captureToCanvas(browser, canvas).catch(e => console.error(e));
dragImageOffset = dragImageOffset * scale;
}
event.dataTransfer.setDragImage(toDrag, dragImageOffset, dragImageOffset);
return true;
};
onBrowserDragOver = event => {
event.preventDefault();
const browser = event.target.querySelector("browser");
if (!browser) {
return;
}
const tab = gBrowser.getTabForBrowser(browser);
if (tab === this._draggingTab) {
if (this.dropZone.hasAttribute("enabled")) {
this.dropZone.removeAttribute("enabled");
}
return;
}
if (!this.dropZone.hasAttribute("enabled")) {
this.dropZone.setAttribute("enabled", true);
}
const splitNode = this.getSplitNodeFromTab(tab);
if (!splitNode) {
return;
}
const posToRoot = { ...splitNode.positionToRoot };
const browserRect = browser.getBoundingClientRect();
const hoverSide = this.calculateHoverSide(
event.clientX,
event.clientY,
browserRect
);
if (hoverSide !== "center") {
const isVertical = hoverSide === "top" || hoverSide === "bottom";
const browserSize =
100 -
(isVertical
? posToRoot.top + posToRoot.bottom
: posToRoot.right + posToRoot.left);
const reduce = browserSize * 0.5;
posToRoot[this._oppositeSide(hoverSide)] += reduce;
}
const newInset = `${posToRoot.top}% ${posToRoot.right}% ${posToRoot.bottom}% ${posToRoot.left}%`;
if (this.dropZone.style.inset !== newInset) {
window.requestAnimationFrame(
() => (this.dropZone.style.inset = newInset)
);
}
};
onBrowserDragEnd = event => {
this.dropZone?.removeAttribute("enabled");
// If we don't have drag state, just clean up what we can
if (!this._dragState) {
this._draggingTab = null;
return;
}
const { browser, isSplitHeaderDrag } = this._dragState;
if (browser) {
browser.style.opacity = isSplitHeaderDrag ? "1" : ".85";
}
// Handle split view specific cleanup
if (isSplitHeaderDrag) {
this.disableTabRearrangeView(event);
}
// Clear state
this._draggingTab = null;
this._dragState = null;
};
_oppositeSide(side) {
if (side === "top") {
return "bottom";
}
if (side === "bottom") {
return "top";
}
if (side === "left") {
return "right";
}
if (side === "right") {
return "left";
}
return undefined;
}
calculateHoverSide(x, y, elementRect) {
const hPixelHoverSize =
((elementRect.right - elementRect.left) * this._edgeHoverSize) / 100;
const vPixelHoverSize =
((elementRect.bottom - elementRect.top) * this._edgeHoverSize) / 100;
if (x <= elementRect.left + hPixelHoverSize) {
return "left";
}
if (x > elementRect.right - hPixelHoverSize) {
return "right";
}
if (y <= elementRect.top + vPixelHoverSize) {
return "top";
}
if (y > elementRect.bottom - vPixelHoverSize) {
return "bottom";
}
return "center";
}
onBrowserDrop = event => {
const browserDroppedOn = event.target.querySelector("browser");
if (!browserDroppedOn) {
return;
}
const droppedTab = this._draggingTab;
if (!droppedTab) {
return;
}
const droppedOnTab = gBrowser.getTabForBrowser(
event.target.querySelector("browser")
);
if (droppedTab === droppedOnTab) {
return;
}
const hoverSide = this.calculateHoverSide(
event.clientX,
event.clientY,
browserDroppedOn.getBoundingClientRect()
);
const droppedSplitNode = this.getSplitNodeFromTab(droppedTab);
const droppedOnSplitNode = this.getSplitNodeFromTab(droppedOnTab);
if (hoverSide === "center") {
this.swapNodes(droppedSplitNode, droppedOnSplitNode);
this.applyGridLayout(this._data[this.currentView].layoutTree);
return;
}
this.removeNode(droppedSplitNode);
this.splitIntoNode(droppedOnSplitNode, droppedSplitNode, hoverSide, 0.5);
this.activateSplitView(this._data[this.currentView], true);
};
/**
*
* @param {object} node1
* @param {object} node2
*/
swapNodes(node1, node2) {
this._swapField("sizeInParent", node1, node2);
const node1Idx = node1.parent.children.indexOf(node1);
const node2Idx = node2.parent.children.indexOf(node2);
node1.parent.children[node1Idx] = node2;
node2.parent.children[node2Idx] = node1;
this._swapField("parent", node1, node2);
}
/**
*
* @param {object} node
* @param {object} nodeToInsert
* @param {string} side
* @param {number} sizeOfInsertedNode percentage of node width or height that nodeToInsert will take
*/
splitIntoNode(node, nodeToInsert, side, sizeOfInsertedNode) {
const splitDirection =
side === "left" || side === "right" ? "row" : "column";
const splitPosition = side === "left" || side === "top" ? 0 : 1;
let nodeSize;
let newParent;
if (splitDirection === node.parent?.direction) {
newParent = node.parent;
nodeSize = node.sizeInParent;
} else {
nodeSize = 100;
newParent = new nsSplitNode(splitDirection, node.sizeInParent);
if (node.parent) {
newParent.parent = node.parent;
const nodeIndex = node.parent.children.indexOf(node);
node.parent.children[nodeIndex] = newParent;
} else {
const viewData = Object.values(this._data).find(
s => s.layoutTree === node
);
viewData.layoutTree = newParent;
}
newParent.addChild(node);
}
node.sizeInParent = (1 - sizeOfInsertedNode) * nodeSize;
nodeToInsert.sizeInParent = nodeSize * sizeOfInsertedNode;
const index = newParent.children.indexOf(node);
newParent.children.splice(index + splitPosition, 0, nodeToInsert);
nodeToInsert.parent = newParent;
}
_swapField(fieldName, obj1, obj2) {
const swap = obj1[fieldName];
obj1[fieldName] = obj2[fieldName];
obj2[fieldName] = swap;
}
/**
* Resets the state of a tab.
*
* @param {Tab} tab - The tab to reset.
* @param {boolean} forUnsplit - Indicates if the tab is being reset for unsplitting.
*/
resetTabState(tab, forUnsplit) {
tab.splitView = false;
delete tab.splitViewValue;
tab.removeAttribute("split-view");
tab.linkedBrowser.zenModeActive = false;
const container = tab.linkedBrowser.closest(".browserSidebarContainer");
container.removeAttribute("is-zen-split");
container.style.inset = "";
this._removeHeader(container);
this.resetContainerStyle(container);
container.removeEventListener("mousedown", this.handleTabEvent);
if (!forUnsplit) {
tab.linkedBrowser.docShellIsActive = false;
}
}
/**
* Dispatches a custom event on a tab.
*
* @param {string} eventName - The name of the event to dispatch.
* @param {HTMLElement} item - The item on which to dispatch the event.
*/
#dispatchItemEvent(eventName, item) {
const event = new CustomEvent(eventName, {
detail: { item },
bubbles: true,
cancelable: false,
});
item.dispatchEvent(event);
}
/**
* Removes a group.
*
* @param {number} groupIndex - The index of the group to remove.
*/
removeGroup(groupIndex) {
const group = this._data[groupIndex];
for (const tab of group.tabs.reverse()) {
if (tab.group?.hasAttribute("split-view-group")) {
gBrowser.ungroupTab(tab);
this.#dispatchItemEvent("ZenTabRemovedFromSplit", tab);
}
}
if (this.currentView === groupIndex) {
this.deactivateCurrentSplitView();
}
for (const tab of this._data[groupIndex].tabs) {
this.resetTabState(tab, true);
}
this._data.splice(groupIndex, 1);
}
/**
* context menu item display update
*/
insetUpdateContextMenuItems() {
const contentAreaContextMenu = document.getElementById("tabContextMenu");
contentAreaContextMenu.addEventListener("popupshowing", () => {
let contextTab = TabContextMenu.contextTab;
if (!contextTab) {
return;
}
let selectedTabs = contextTab.multiselected
? gBrowser.selectedTabs
: [contextTab];
let isExistingSplitView = selectedTabs.every(tab =>
tab.group?.hasAttribute("split-view-group")
);
const splitTabCommand = document.getElementById("context_zenSplitTabs");
document.l10n.setAttributes(splitTabCommand, "tab-zen-split-tabs", {
tabCount: isExistingSplitView ? -1 : selectedTabs.length,
});
if (isExistingSplitView) {
splitTabCommand.removeAttribute("hidden");
return;
}
if (!this.contextCanSplitTabs()) {
splitTabCommand.setAttribute("hidden", "true");
} else {
splitTabCommand.removeAttribute("hidden");
}
});
}
/**
* Inserts the split view tab context menu item.
*/
insertSplitViewTabContextMenu() {
const element = window.MozXULElement.parseXULToFragment(`
`);
document.getElementById("context_moveTabToSplitView").before(element);
}
/**
* Initializes the context menu.
*/
initializeContextMenu() {
this.insertSplitViewTabContextMenu();
this.insetUpdateContextMenuItems();
}
/**
* Gets the tab browser panel.
*
* @returns {Element} The tab browser panel.
*/
get tabBrowserPanel() {
return gBrowser.tabpanels;
}
get splitViewActive() {
return this.currentView >= 0;
}
/**
* Splits a link in a new tab.
*/
splitLinkInNewTab() {
const url =
window.gContextMenu.linkURL ||
window.gContextMenu.mediaURL ||
window.gContextMenu.contentData.docLocation ||
window.gContextMenu.target.ownerDocument.location.href;
const currentTab = gZenGlanceManager.getTabOrGlanceParent(
window.gBrowser.selectedTab
);
const newTab = this.openAndSwitchToTab(url, { inBackground: false });
this.splitTabs([currentTab, newTab], undefined, 1);
}
/**
* Splits the selected tabs.
*
* @param {Tab|null} otherTabHint - An optional hint for another tab to split with (used for glance tabs).
*/
contextSplitTabs(otherTabHint = null) {
let tabs;
let currentTab = TabContextMenu.contextTab || gBrowser.selectedTab;
if (currentTab.multiselected) {
tabs = gBrowser.selectedTabs;
} else if (!currentTab.selected) {
tabs = [
currentTab,
...gBrowser.selectedTabs.filter(t => t !== currentTab),
];
} else {
tabs = [currentTab];
}
if (otherTabHint && !tabs.includes(otherTabHint)) {
tabs.push(otherTabHint);
}
if (tabs.length < 2) {
gBrowser.selectedTab = tabs[0];
this.createEmptySplit();
return;
}
// If all are already in a split view, we unsplit them first.
if (tabs.every(tab => tab.splitView)) {
for (const tab of tabs) {
if (tab.splitView) {
this.removeTabFromGroup(tab);
}
}
return;
}
this.splitTabs(tabs);
}
/**
* Checks if the selected tabs can be split.
*
* @returns {boolean} True if the tabs can be split, false otherwise.
*/
contextCanSplitTabs() {
if (window.gBrowser.selectedTabs.length > this.MAX_TABS) {
return false;
}
for (const tab of window.gBrowser.selectedTabs) {
if (tab.hasAttribute("zen-empty-tab")) {
return false;
}
}
return true;
}
/**
* Handles the location change event.
*
* @param {Browser} browser - The browser instance.
*/
onLocationChange(browser) {
this.disableTabRearrangeView();
let tab = window.gBrowser.getTabForBrowser(browser);
const ignoreSplit = tab.hasAttribute("zen-dont-split-glance");
tab.removeAttribute("zen-dont-split-glance");
let isGlanceTab = false;
if (tab.hasAttribute("zen-glance-tab") && !ignoreSplit) {
// Extract from parent node so we are not selecting the wrong (current) tab
tab = tab.parentNode.closest(".tabbrowser-tab");
isGlanceTab = true;
if (!tab) {
console.error("Tab not found for zen-glance-tab");
}
}
if (tab) {
this.updateSplitView(tab);
tab.linkedBrowser.docShellIsActive = true;
if (isGlanceTab) {
// See issues https://github.com/zen-browser/desktop/issues/11641
this.removeSplitters();
}
}
this._maybeRemoveFakeBrowser();
{
const shouldDisableEmptySplits =
tab.hasAttribute("zen-empty-tab") || tab.splitView;
const command = document.getElementById("cmd_zenNewEmptySplit");
if (shouldDisableEmptySplits) {
command.setAttribute("disabled", "true");
} else {
command.removeAttribute("disabled");
}
}
}
/**
* @param {Array} tabs
* @param {Tab} relativeTab
*/
#moveTabsToContainer(tabs, relativeTab) {
const relativeTabIsPinned = relativeTab.pinned;
const relativeTabIsEssential = relativeTab.hasAttribute("zen-essential");
if (relativeTabIsEssential) {
gZenPinnedTabManager.addToEssentials(tabs);
} else {
for (const tab of tabs) {
if (relativeTabIsPinned) {
gBrowser.pinTab(tab);
} else {
gBrowser.unpinTab(tab);
}
}
}
}
#useTabsToSplit(tabs) {
// If there's ANY pinned tab on the list, we clone the pinned tab
// state to all the tabs
const allArePinned = tabs.every(tab => tab.pinned);
const thereIsOnePinned = tabs.some(tab => tab.pinned);
const thereIsOneEssential = tabs.some(tab =>
tab.hasAttribute("zen-essential")
);
const thereIsOneLiveFolder = tabs.some(tab =>
tab.hasAttribute("zen-live-folder-item-id")
);
if (
thereIsOneEssential ||
(thereIsOnePinned && !allArePinned) ||
thereIsOneLiveFolder
) {
for (let i = 0; i < tabs.length; i++) {
const tab = tabs[i];
if (tab.pinned) {
tabs[i] = gBrowser.duplicateTab(tab, true);
}
}
}
}
/**
* Splits the given tabs.
*
* @param {Tab[]} tabs - The tabs to split.
* @param {string|undefined} gridType - The type of grid layout.
* @param {number} initialIndex - The index of the initially active tab.
* use -1 to avoid selecting any tab.
* @param {object} options - Additional options.
* @param {string|null} options.groupFetchId - An optional group fetch ID.
* @returns {object|undefined} The split view data or undefined if the split was not performed.
*/
splitTabs(tabs, gridType, initialIndex = 0, { groupFetchId = null } = {}) {
const tabIndexToUse = Math.max(0, initialIndex);
return this.#withoutSplitViewTransition(() => {
// TODO: Add support for splitting essential tabs
tabs = tabs.filter(t => !t.hidden && !t.hasAttribute("zen-empty-tab"));
if (tabs.length < 2 || tabs.length > this.MAX_TABS) {
return;
}
const existingSplitTab = tabs.find(tab => tab.splitView);
let shouldActivateSplit =
(initialIndex >= 0 || tabs.includes(window.gBrowser.selectedTab)) &&
!this._sessionRestoring;
if (existingSplitTab) {
const groupIndex = this._data.findIndex(group =>
group.tabs.includes(existingSplitTab)
);
const group = this._data[groupIndex];
if (group.tabs.length >= this.MAX_TABS) {
gZenUIManager.showToast("zen-split-view-limit-toast");
return;
}
this.#useTabsToSplit(tabs);
this.#moveTabsToContainer(tabs, tabs[tabIndexToUse]);
const gridTypeChange = gridType && group.gridType !== gridType;
const newTabsAdded = tabs.find(t => !group.tabs.includes(t));
if (gridTypeChange && !newTabsAdded) {
// reset layout
group.gridType = gridType;
group.layoutTree = this.calculateLayoutTree(
[...new Set(group.tabs.concat(tabs))],
gridType
);
} else {
// Add any tabs that are not already in the group
for (let i = 0; i < tabs.length; i++) {
const tab = tabs[i];
if (!group.tabs.includes(tab)) {
gBrowser.moveTabToExistingGroup(
tab,
this._getSplitViewGroup(tabs, groupFetchId)
);
group.tabs.push(tab);
this.addTabToSplit(tab, group.layoutTree, false);
tab.splitView = true;
}
}
}
this.#dispatchItemEvent("ZenSplitViewTabsSplit", group.tabs[0].group);
if (!shouldActivateSplit) {
return group;
}
this.activateSplitView(group, true);
// eslint-disable-next-line consistent-return
return group;
}
this.#useTabsToSplit(tabs);
gridType ??= "grid";
// Add tabs to the split view group
let splitGroup = this._getSplitViewGroup(tabs, groupFetchId);
const groupId = splitGroup?.id;
if (splitGroup) {
for (const tab of tabs) {
if (!tab.group || tab.group !== splitGroup) {
gBrowser.moveTabToExistingGroup(tab, splitGroup);
}
}
}
const splitData = {
groupId,
tabs,
gridType,
layoutTree: this.calculateLayoutTree(tabs, gridType),
};
this._data.push(splitData);
if (shouldActivateSplit) {
window.gBrowser.selectedTab = tabs[tabIndexToUse] ?? tabs[0];
this.activateSplitView(splitData);
} else {
for (const tab of tabs) {
tab.splitView = true;
}
}
this.#dispatchItemEvent("ZenSplitViewTabsSplit", splitGroup);
// eslint-disable-next-line consistent-return
return splitData;
});
}
addTabToSplit(tab, splitNode, prepend = true) {
const reduce = splitNode.children.length / (splitNode.children.length + 1);
splitNode.children.forEach(c => (c.sizeInParent *= reduce));
splitNode.addChild(new nsSplitLeafNode(tab, (1 - reduce) * 100), prepend);
}
/**
* Updates the split view.
*
* @param {Tab} tab - The tab to update the split view for.
*/
updateSplitView(tab) {
const oldView = this.currentView;
const newView = this._data.findIndex(group => group.tabs.includes(tab));
if (newView === oldView && oldView < 0) {
return;
}
if (newView < 0 && oldView >= 0) {
this.deactivateCurrentSplitView();
return;
}
this.disableTabRearrangeView();
this.activateSplitView(this._data[newView]);
}
/**
* Deactivates the split view.
*
* @param {object} options - Options object.
* @param {boolean} options.removeDeckSelected - Whether to remove deck selected attribute.
*/
deactivateCurrentSplitView({ removeDeckSelected = false } = {}) {
if (this.currentView < 0) {
return;
}
this.setTabsDocShellState(this._data[this.currentView].tabs, false);
for (const tab of this._data[this.currentView].tabs) {
const container = tab.linkedBrowser.closest(".browserSidebarContainer");
this.resetContainerStyle(container, removeDeckSelected);
}
this.removeSplitters();
this.tabBrowserPanel.removeAttribute("zen-split-view");
document
.getElementById("tabbrowser-tabbox")
.removeAttribute("zen-split-view");
this.currentView = -1;
this.toggleWrapperDisplay(false);
this.maybeDisableOpeningTabOnSplitView();
window.dispatchEvent(
new CustomEvent("ZenViewSplitter:SplitViewDeactivated")
);
}
/**
* Activates the split view.
*
* @param {object} splitData - The split data.
* @param {boolean} reset - Whether to reset the split view.
*/
activateSplitView(splitData, reset = false) {
const oldView = this.currentView;
const newView = this._data.indexOf(splitData);
if (oldView >= 0 && oldView !== newView) {
this.deactivateCurrentSplitView();
}
this.currentView = newView;
if (reset) {
this.removeSplitters();
}
splitData.tabs.forEach(tab => {
if (tab.hasAttribute("pending")) {
gBrowser.getBrowserForTab(tab).reload();
}
});
// Apply grid to tabs first to set zen-split attribute on containers
// before setting zen-split-view on parents. This prevents the black flash
// caused by CSS rules that hide containers without zen-split attribute
// when the parent has zen-split-view attribute.
this.applyGridToTabs(splitData.tabs);
this.tabBrowserPanel.setAttribute("zen-split-view", "true");
document
.getElementById("tabbrowser-tabbox")
.setAttribute("zen-split-view", "true");
this.applyGridLayout(splitData.layoutTree);
this.setTabsDocShellState(splitData.tabs, true);
this.toggleWrapperDisplay(true);
window.dispatchEvent(new CustomEvent("ZenViewSplitter:SplitViewActivated"));
}
calculateLayoutTree(tabs, gridType) {
let rootNode;
if (gridType === "vsep" || (tabs.length === 2 && gridType === "grid")) {
rootNode = new nsSplitNode("row");
rootNode.children = tabs.map(
tab => new nsSplitLeafNode(tab, 100 / tabs.length)
);
} else if (gridType === "hsep") {
rootNode = new nsSplitNode("column");
rootNode.children = tabs.map(
tab => new nsSplitLeafNode(tab, 100 / tabs.length)
);
} else if (gridType === "grid") {
rootNode = new nsSplitNode("row");
const rowWidth = 100 / Math.ceil(tabs.length / 2);
for (let i = 0; i < tabs.length - 1; i += 2) {
const columnNode = new nsSplitNode("column", rowWidth, 100);
columnNode.children = [
new nsSplitLeafNode(tabs[i], 50),
new nsSplitLeafNode(tabs[i + 1], 50),
];
rootNode.addChild(columnNode, false);
}
if (tabs.length % 2 !== 0) {
rootNode.addChild(
new nsSplitLeafNode(tabs[tabs.length - 1], rowWidth),
false
);
}
}
return rootNode;
}
/**
* Applies the grid layout to the tabs.
*
* @param {Tab[]} tabs - The tabs to apply the grid layout to.
*/
applyGridToTabs(tabs) {
tabs.forEach(tab => {
tab.splitView = true;
tab.splitViewValue = this.currentView;
tab.setAttribute("split-view", "true");
const container = tab.linkedBrowser?.closest(".browserSidebarContainer");
container.setAttribute("is-zen-split", "true");
// Set zen-split early to prevent visibility flash when switching workspaces.
// The CSS rule for [is-zen-split]:not([zen-split]) sets visibility:hidden,
// so we must set zen-split before the parent zen-split-view attribute is applied.
container.setAttribute("zen-split", "true");
if (!container?.querySelector(".zen-tab-rearrange-button")) {
// insert a header into the container
const header = this._createHeader(container);
container.insertBefore(header, container.firstChild);
}
this.styleContainer(container);
});
}
/**
* Creates a header for the tab.
*
* @param {Element} container
* @returns {*|!Element|HTMLElement|HTMLUnknownElement|HTMLDirectoryElement|HTMLFontElement|HTMLFrameElement|HTMLFrameSetElement|HTMLPreElement|HTMLMarqueeElement|HTMLParamElement}
* @private
*/
_createHeader(container) {
const headerContainer = document.createElement("div");
headerContainer.classList.add("zen-view-splitter-header-container");
const header = document.createElement("div");
header.classList.add("zen-view-splitter-header");
const removeButton = document.createXULElement("toolbarbutton");
removeButton.classList.add("zen-tab-unsplit-button");
removeButton.addEventListener("click", event => {
this.removeTabFromSplit(event, container);
});
const rearrangeButton = document.createXULElement("toolbarbutton");
rearrangeButton.classList.add("zen-tab-rearrange-button");
header.appendChild(rearrangeButton);
header.appendChild(removeButton);
headerContainer.appendChild(header);
return headerContainer;
}
_removeHeader(container) {
const header = container.querySelector(
".zen-view-splitter-header-container"
);
if (header) {
header.remove();
}
}
/**
* Apply grid layout to tabBrowserPanel
*
* @param {nsSplitNode} splitNode nsSplitNode
*/
applyGridLayout(splitNode) {
if (!splitNode.positionToRoot) {
splitNode.positionToRoot = { top: 0, bottom: 0, left: 0, right: 0 };
}
const nodeRootPosition = splitNode.positionToRoot;
if (!splitNode.children) {
const browserContainer = splitNode.tab.linkedBrowser.closest(
".browserSidebarContainer"
);
browserContainer.style.inset = `${nodeRootPosition.top}% ${nodeRootPosition.right}% ${nodeRootPosition.bottom}% ${nodeRootPosition.left}%`;
this._tabToSplitNode.set(splitNode.tab, splitNode);
return;
}
const rootToNodeWidthRatio =
(100 - nodeRootPosition.right - nodeRootPosition.left) / 100;
const rootToNodeHeightRatio =
(100 - nodeRootPosition.bottom - nodeRootPosition.top) / 100;
const splittersNeeded = splitNode.children.length - 1;
const currentSplitters = this.getSplitters(splitNode, splittersNeeded);
let leftOffset = nodeRootPosition.left;
let topOffset = nodeRootPosition.top;
splitNode.children.forEach((childNode, i) => {
const childRootPosition = {
top: topOffset,
right:
100 - (leftOffset + childNode.widthInParent * rootToNodeWidthRatio),
bottom:
100 - (topOffset + childNode.heightInParent * rootToNodeHeightRatio),
left: leftOffset,
};
childNode.positionToRoot = childRootPosition;
this.applyGridLayout(childNode);
if (splitNode.direction === "column") {
topOffset += childNode.sizeInParent * rootToNodeHeightRatio;
} else {
leftOffset += childNode.sizeInParent * rootToNodeWidthRatio;
}
if (i < splittersNeeded) {
const splitter = currentSplitters[i];
if (splitNode.direction === "column") {
splitter.style.inset = `${100 - childRootPosition.bottom}% ${childRootPosition.right}% 0% ${childRootPosition.left}%`;
} else {
splitter.style.inset = `${childRootPosition.top}% 0% ${childRootPosition.bottom}% ${100 - childRootPosition.right}%`;
}
}
});
this.maybeDisableOpeningTabOnSplitView();
}
/**
*
* @param {string} orient
* @param {nsSplitNode} parentNode
* @param {number} idx
*/
createSplitter(orient, parentNode, idx) {
const splitter = document.createElement("div");
splitter.className = "zen-split-view-splitter";
splitter.setAttribute("orient", orient);
splitter.setAttribute("gridIdx", idx);
this.overlay.insertAdjacentElement("afterbegin", splitter);
splitter.addEventListener("mousedown", this.handleSplitterMouseDown);
return splitter;
}
/**
* @param {nsSplitNode} parentNode
* @param {number|undefined} splittersNeeded if provided the amount of splitters for node will be adjusted to match
*/
getSplitters(parentNode, splittersNeeded) {
let currentSplitters = this._splitNodeToSplitters.get(parentNode) || [];
if (!splittersNeeded || currentSplitters.length === splittersNeeded) {
return currentSplitters;
}
for (let i = currentSplitters?.length || 0; i < splittersNeeded; i++) {
currentSplitters.push(
this.createSplitter(
parentNode.direction === "column" ? "horizontal" : "vertical",
parentNode,
i
)
);
currentSplitters[i].parentSplitNode = parentNode;
}
if (currentSplitters.length > splittersNeeded) {
currentSplitters
.slice(splittersNeeded - currentSplitters.length)
.forEach(s => s.remove());
currentSplitters = currentSplitters.slice(0, splittersNeeded);
}
this._splitNodeToSplitters.set(parentNode, currentSplitters);
return currentSplitters;
}
removeSplitters() {
[...this.overlay.children]
.filter(c => c.classList.contains("zen-split-view-splitter"))
.forEach(s => s.remove());
this._splitNodeToSplitters.clear();
}
/**
* @param {Tab} tab
* @returns {nsSplitNode} splitNode
*/
getSplitNodeFromTab(tab) {
return this._tabToSplitNode.get(tab);
}
/**
* Styles the container for a tab.
*
* @param {Element} container - The container element.
*/
styleContainer(container) {
container.addEventListener("mousedown", this.handleTabEvent);
}
/**
* Handles tab events.
*
* @param {Event} event - The event.
*/
_handleTabEvent = event => {
if (this.rearrangeViewEnabled) {
return;
}
const container = event.currentTarget.closest(".browserSidebarContainer");
const tab = window.gBrowser.tabs.find(
t => t.linkedBrowser?.closest(".browserSidebarContainer") === container
);
if (tab) {
window.gBrowser.selectedTab = tab;
}
};
handleSplitterMouseDown = event => {
this.tabBrowserPanel.setAttribute("zen-split-resizing", true);
const isVertical = event.target.getAttribute("orient") === "vertical";
const dimension = isVertical ? "width" : "height";
const clientAxis = isVertical ? "clientX" : "clientY";
const gridIdx = parseInt(event.target.getAttribute("gridIdx"));
const startPosition = event[clientAxis];
const splitNode = event.target.parentSplitNode;
let rootToNodeSize;
if (isVertical) {
rootToNodeSize =
100 /
(100 - splitNode.positionToRoot.right - splitNode.positionToRoot.left);
} else {
rootToNodeSize =
100 /
(100 - splitNode.positionToRoot.bottom - splitNode.positionToRoot.top);
}
const originalSizes = splitNode.children.map(c => c.sizeInParent);
const dragFunc = dEvent => {
requestAnimationFrame(() => {
originalSizes.forEach(
(s, i) => (splitNode.children[i].sizeInParent = s)
); // reset changes
const movement = dEvent[clientAxis] - startPosition;
let movementPercent =
(movement / this.tabBrowserPanel.getBoundingClientRect()[dimension]) *
rootToNodeSize *
100;
let reducingMovement = Math.max(movementPercent, -movementPercent);
for (
let i = gridIdx + (movementPercent < 0 ? 0 : 1);
0 <= i && i < originalSizes.length;
i += movementPercent < 0 ? -1 : 1
) {
const current = originalSizes[i];
const newSize = Math.max(
this.minResizeWidth,
current - reducingMovement
);
splitNode.children[i].sizeInParent = newSize;
const amountReduced = current - newSize;
reducingMovement -= amountReduced;
if (reducingMovement <= 0) {
break;
}
}
const increasingMovement =
Math.max(movementPercent, -movementPercent) - reducingMovement;
const increaseIndex = gridIdx + (movementPercent < 0 ? 1 : 0);
splitNode.children[increaseIndex].sizeInParent =
originalSizes[increaseIndex] + increasingMovement;
this.applyGridLayout(splitNode);
});
};
window.setCursor(isVertical ? "ew-resize" : "ns-resize");
document.addEventListener("mousemove", dragFunc);
document.addEventListener(
"mouseup",
() => {
document.removeEventListener("mousemove", dragFunc);
window.setCursor("auto");
this.tabBrowserPanel.removeAttribute("zen-split-resizing");
},
{ once: true }
);
};
/**
* Sets the docshell state for the tabs.
*
* @param {Tab[]} tabs - The tabs.
* @param {boolean} active - Indicates if the tabs are active.
*/
setTabsDocShellState(tabs, active) {
for (const tab of tabs) {
// zenModeActive allow us to avoid setting docShellisActive to false later on,
// see browser-custom-elements.js's patch
tab.linkedBrowser.zenModeActive = active;
if (!active && tab === gBrowser.selectedTab) {
continue;
}
try {
tab.linkedBrowser.docShellIsActive = active;
} catch (e) {
console.error(e);
}
const browser = tab.linkedBrowser.closest(".browserSidebarContainer");
if (active) {
tab.removeAttribute("pending");
browser.setAttribute("zen-split", "true");
browser.addEventListener("dragstart", this.onBrowserDragStart);
browser.addEventListener("dragend", this.onBrowserDragEnd);
} else {
// browser.removeAttribute('zen-split');
// browser.removeAttribute('style');
browser.removeEventListener("dragstart", this.onBrowserDragStart);
browser.removeEventListener("dragend", this.onBrowserDragEnd);
}
}
}
/**
* Resets the container style.
*
* @param {Element} container - The container element.
* @param {boolean} [removeDeckSelected=false] - Whether to remove the 'deck-selected' attribute.
*/
resetContainerStyle(container, removeDeckSelected = false) {
container.removeAttribute("zen-split");
if (removeDeckSelected) {
container.classList.remove("deck-selected");
}
}
/**
* Updates the UI of the panel.
*
* @param {Element} panel - The panel element.
*/
updatePanelUI(panel) {
for (const gridType of ["hsep", "vsep", "grid", "unsplit"]) {
const selector = panel.querySelector(
`.zen-split-view-modifier-preview.${gridType}`
);
selector.classList.remove("active");
if (
this.currentView >= 0 &&
this._data[this.currentView].gridType === gridType
) {
selector.classList.add("active");
}
}
}
/**
* @description unsplit the current view.]
*/
unsplitCurrentView() {
if (this.currentView < 0) {
return;
}
this.removeGroup(this.currentView);
const currentTab = window.gBrowser.selectedTab;
window.gBrowser.selectedTab = currentTab;
}
/**
* @description opens a new tab and switches to it.
* @param {string} url - The url to open
* @param {object} options - The options for the tab
* @returns {tab} The tab that was opened
*/
openAndSwitchToTab(url, options) {
const parentWindow = window.ownerGlobal.parent;
const targetWindow = parentWindow || window;
const tab = targetWindow.gBrowser.addTrustedTab(url, options);
targetWindow.gBrowser.selectedTab = tab;
return tab;
}
toggleShortcut(gridType) {
if (gridType === "unsplit") {
this.unsplitCurrentView();
return;
}
const tabs = gBrowser.visibleTabs;
if (tabs.length < 2) {
return;
}
let nextTabIndex = tabs.indexOf(gBrowser.selectedTab) + 1;
if (nextTabIndex >= tabs.length) {
// Find the first non-hidden tab
nextTabIndex = tabs.findIndex(tab => !tab.hidden);
} else if (nextTabIndex < 0) {
// reverse find the first non-hidden tab
nextTabIndex = tabs
.slice()
.reverse()
.findIndex(tab => !tab.hidden);
}
const selected_tabs = gBrowser.selectedTab.multiselected
? gBrowser.selectedTabs
: [gBrowser.selectedTab, tabs[nextTabIndex]];
// Check if tabs from split view they must be from the same group
if (this.currentView >= 0) {
const splitViewId = this._data[this.currentView].groupId;
const sameSplitView = selected_tabs.every(
tab => !tab?.group || tab.group.id === splitViewId
);
if (!sameSplitView) {
return;
}
}
this.splitTabs(selected_tabs, gridType);
}
/**
* @description removes the tab from the split
* @param {Event} event - The click event
* @param {Element} container - The container element
*/
removeTabFromSplit = (event, container) => {
const browser = container.querySelector("browser");
if (browser) {
const tab = gBrowser.getTabForBrowser(browser);
if (tab) {
const groupIndex = this._data.findIndex(group =>
group.tabs.includes(tab)
);
this.deactivateCurrentSplitView();
if (groupIndex >= 0) {
this.removeTabFromGroup(tab, groupIndex, { forUnsplit: true });
}
if (!event.shiftKey) {
gBrowser.selectedTab = tab;
tab._selected = true;
}
}
}
};
_maybeRemoveFakeBrowser(select = true) {
gBrowser.tabbox.removeAttribute("style");
this.tabBrowserPanel.removeAttribute("dragging-split");
if (this._dndElement) {
this._dndElement.remove();
delete this._dndElement;
}
if (this.fakeBrowser) {
delete this._canDrop;
delete this._hasAnimated;
this.fakeBrowser.remove();
delete this.fakeBrowser;
if (select) {
gBrowser.selectedTab = this._draggingTab;
this._draggingTab = null;
}
for (const browser of gBrowser.browsers) {
browser.removeAttribute("style");
}
}
}
/**
* @description moves the tab to the split view if dragged on a browser
* @param {Event} event - The event
* @param {Tab} draggedTab - The dragged tab
* @returns {boolean} true if the tab was moved to the split view
*/
// eslint-disable-next-line complexity
moveTabToSplitView(event, draggedTab) {
const canDrop = this._canDrop;
this._canDrop = false;
if (!canDrop || !this.fakeBrowser) {
this._maybeRemoveFakeBrowser(false);
return false;
}
// CHeck if it's inside the tabbox
const tabboxRect = gBrowser.tabbox.getBoundingClientRect();
const elementSeparation = ZenThemeModifier.elementSeparation;
if (
event.clientX < tabboxRect.left ||
event.clientX > tabboxRect.right - elementSeparation ||
event.clientY < tabboxRect.top ||
event.clientY > tabboxRect.bottom - elementSeparation
) {
this._maybeRemoveFakeBrowser(false);
return false;
}
const dropSide = this.fakeBrowser?.getAttribute("side");
const containerRect = this.fakeBrowser.getBoundingClientRect();
const padding = ZenThemeModifier.elementSeparation;
let targetX = event.clientX;
let targetY = event.clientY;
switch (dropSide) {
case "left":
targetX = containerRect.left + containerRect.width + padding + 5;
break;
case "right":
targetX = containerRect.left - padding - 5;
break;
case "top":
targetY = containerRect.top + containerRect.height + padding + 5;
break;
case "bottom":
targetY = containerRect.top - padding - 5;
break;
}
const dropTarget = document.elementFromPoint(targetX, targetY);
const browser =
dropTarget?.closest("browser") ??
dropTarget?.closest(".browserSidebarContainer")?.querySelector("browser");
if (!browser) {
this._maybeRemoveFakeBrowser(false);
return false;
}
let droppedOnTab = gZenGlanceManager.getTabOrGlanceParent(
gBrowser.getTabForBrowser(browser)
);
if (droppedOnTab === this._draggingTab) {
this.createEmptySplit(dropSide);
return true;
}
gBrowser.selectedTab = this._draggingTab;
this._draggingTab = null;
const browserContainer = draggedTab.linkedBrowser?.closest(
".browserSidebarContainer"
);
if (browserContainer) {
browserContainer.style.opacity = "0";
}
if (droppedOnTab && droppedOnTab !== draggedTab) {
// Calculate which side of the target browser the drop occurred
// const browserRect = browser.getBoundingClientRect();
// const hoverSide = this.calculateHoverSide(event.clientX, event.clientY, browserRect);
const hoverSide = dropSide;
// We are here if none of the tabs have been previously split
// If there's ANY pinned tab on the list, we clone the pinned tab
// state to all the tabs
let tempTabs = [draggedTab, droppedOnTab];
const allArePinned = tempTabs.every(tab => tab.pinned);
const thereIsOnePinned = tempTabs.some(tab => tab.pinned);
const thereIsOneEssential = tempTabs.some(tab =>
tab.hasAttribute("zen-essential")
);
if (thereIsOneEssential || (thereIsOnePinned && !allArePinned)) {
for (let i = 0; i < tempTabs.length; i++) {
const tab = tempTabs[i];
if (tab.pinned) {
tempTabs[i] = gBrowser.duplicateTab(tab, true);
}
}
}
[draggedTab, droppedOnTab] = tempTabs;
if (droppedOnTab.splitView) {
// Add to existing split view
const groupIndex = this._data.findIndex(group =>
group.tabs.includes(droppedOnTab)
);
const group = this._data[groupIndex];
if (
!group.tabs.includes(draggedTab) &&
group.tabs.length < this.MAX_TABS
) {
// First move the tab to the split view group
let splitGroup = droppedOnTab.group;
if (
splitGroup &&
(!draggedTab.group || draggedTab.group !== splitGroup)
) {
this.#moveTabsToContainer([draggedTab], droppedOnTab);
gBrowser.moveTabToExistingGroup(draggedTab, splitGroup);
if (hoverSide === "left" || hoverSide === "top") {
try {
splitGroup.tabs[0].before(draggedTab);
} catch (e) {
console.warn(
`Failed to move tab ${draggedTab.id} before ${splitGroup.tabs[0].id}: ${e}`
);
}
}
}
// Then add the tab to the split view
group.tabs.push(draggedTab);
// If dropping on a side, wrap entire layout in a new split at the root level
if (hoverSide !== "center") {
const splitDirection =
hoverSide === "left" || hoverSide === "right" ? "row" : "column";
const rootNode = group.layoutTree;
const prepend = hoverSide === "left" || hoverSide === "top";
if (rootNode.direction === splitDirection) {
// Root has the same direction, add as a new child of the root
this.addTabToSplit(draggedTab, rootNode, prepend);
} else {
// Different direction, wrap root in a new split node
this.splitIntoNode(
rootNode,
new nsSplitLeafNode(draggedTab, 50),
hoverSide,
0.5
);
}
} else {
this.addTabToSplit(draggedTab, group.layoutTree);
}
this.activateSplitView(group, true);
}
} else {
// Create new split view with layout based on drop position
const gridType =
dropSide === "top" || dropSide === "bottom" ? "hsep" : "vsep";
const topOrLeft = dropSide === "top" || dropSide === "left";
// Put tabs always as if it was dropped from the left
this.splitTabs(
topOrLeft ? [draggedTab, droppedOnTab] : [droppedOnTab, draggedTab],
gridType,
topOrLeft ? 0 : 1
);
}
gBrowser.selectedTab = draggedTab;
}
if (this._finishAllAnimatingPromise) {
this._finishAllAnimatingPromise.then(() => {
this._maybeRemoveFakeBrowser(false);
});
}
if (browserContainer) {
this.animateBrowserDrop(browserContainer, () => {
this._maybeRemoveFakeBrowser(false);
this._finishAllAnimatingPromise = null;
});
}
return true;
}
animateBrowserDrop(browserContainer, callback = () => {}) {
gZenUIManager.motion
.animate(
browserContainer,
{
scale: [0.97, 1],
opacity: [0, 1],
},
{
type: "spring",
bounce: 0.4,
duration: 0.2,
delay: 0.1,
}
)
.then(callback);
}
handleTabDrop(event, urls, replace) {
if (replace || urls.length !== 1) {
return false;
}
const url = urls[0];
if (!url.startsWith("panel-")) {
return false;
}
const browserContainer = document.getElementById(url);
const browser = browserContainer?.querySelector("browser");
if (!browser) {
return false;
}
const tab = gBrowser.getTabForBrowser(browser);
if (!tab) {
return false;
}
if (tab.splitView) {
// Unsplit the tab and exit from the drag view
this.dropZone?.removeAttribute("enabled");
this.disableTabRearrangeView(event);
this.removeTabFromSplit(browserContainer);
return true;
}
return false;
}
/**
* Gets or creates a tab group for split view tabs
*
* @param {Array} tabs Initial tabs to add to the group if creating new
* @param {string|null} id Optional ID for the group
* @returns {TabGroup} The tab group for split view tabs
*/
_getSplitViewGroup(tabs, id = null) {
if (tabs.some(tab => tab.hasAttribute("zen-essential"))) {
return null;
}
// Try to find an existing split view group
let splitGroup = tabs?.find(tab =>
tab.group?.hasAttribute("split-view-group")
)?.group;
if (splitGroup) {
return splitGroup;
}
// We can't create an empty group, so only create if we have tabs
if (tabs?.length) {
// Create a new group with the initial tabs
splitGroup = gBrowser.addTabGroup(tabs, {
id,
label: "",
showCreateUI: false,
insertBefore: tabs[0],
forSplitView: true,
});
}
return splitGroup;
}
storeDataForSessionStore() {
const serializeNode = node => {
if (node.tab) {
return {
type: "leaf",
tabId: node.tab.id,
sizeInParent: node.sizeInParent,
};
}
return {
type: "splitter",
direction: node.direction,
sizeInParent: node.sizeInParent,
children: node._children.map(child => serializeNode(child)),
};
};
return this._data.map(group => {
const serializedTree = serializeNode(group.layoutTree);
return {
groupId: group.groupId,
gridType: group.gridType,
layoutTree: serializedTree,
tabs: group.tabs.map(tab => tab.id),
};
});
}
restoreDataFromSessionStore(data) {
if (!data) {
return;
}
this._sessionRestoring = true;
for (const groupData of data) {
try {
const group = document.getElementById(groupData.groupId);
if (!gBrowser.isTabGroup(group)) {
continue;
}
// Backwards compatibility
group.setAttribute("split-view-group", "true");
if (!groupData?.layoutTree) {
this.splitTabs(group.tabs, group.gridType);
delete this._sessionRestoring;
return;
}
const deserializeNode = nodeData => {
if (nodeData.type === "leaf") {
const tab = document.getElementById(nodeData.tabId);
if (!tab) {
return null;
}
return new nsSplitLeafNode(tab, nodeData.sizeInParent);
}
const splitter = new nsSplitNode(
nodeData.direction,
nodeData.sizeInParent
);
splitter._children = [];
for (const childData of nodeData.children) {
const childNode = deserializeNode(childData);
if (childNode) {
childNode.parent = splitter;
splitter._children.push(childNode);
}
}
return splitter;
};
const layout = deserializeNode(groupData.layoutTree);
const splitData = this.splitTabs(group.tabs, groupData.gridType, -1);
if (splitData) {
splitData.layoutTree = layout;
} else {
gBrowser.removeTabGroup(group);
}
} catch (e) {
console.error("Error restoring split view session data:", e);
}
}
delete this._sessionRestoring;
}
onAfterWorkspaceSessionRestore() {
if (
gBrowser.selectedTab.group?.hasAttribute("split-view-group") &&
!gBrowser.selectedTab.pinned
) {
// Activate all browsers in the split view
this.currentView = -1;
this.onLocationChange(gBrowser.selectedTab.linkedBrowser);
}
}
maybeDisableOpeningTabOnSplitView() {
const shouldBeDisabled = !this.canOpenLinkInSplitView();
document
.getElementById("cmd_zenSplitViewLinkInNewTab")
.toggleAttribute("disabled", shouldBeDisabled);
const splitGlanceCommand = document.getElementById("cmd_zenGlanceSplit");
if (shouldBeDisabled) {
splitGlanceCommand.setAttribute("disabled", true);
} else {
splitGlanceCommand.removeAttribute("disabled");
}
}
canOpenLinkInSplitView() {
const currentView = this.currentView;
if (currentView < 0) {
return true;
}
const group = this._data[currentView];
if (!group || group.tabs.length >= this.MAX_TABS) {
return false;
}
return true;
}
#withoutSplitViewTransition(callback) {
this.tabBrowserPanel.classList.add("zen-split-view-no-transition");
try {
return callback();
} finally {
requestAnimationFrame(() => {
this.tabBrowserPanel.classList.remove("zen-split-view-no-transition");
}, 0);
}
}
createEmptySplit(side = "right") {
const selectedTab = gBrowser.selectedTab;
const emptyTab = gZenWorkspaces._emptyTab;
const gridType = side === "top" || side === "bottom" ? "hsep" : "vsep";
const topOrLeft = side === "top" || side === "left";
let tabs = topOrLeft ? [emptyTab, selectedTab] : [selectedTab, emptyTab];
const data = {
tabs,
gridType,
layoutTree: this.calculateLayoutTree(tabs, gridType),
};
this.#withoutSplitViewTransition(() => {
this._data.push(data);
this.activateSplitView(data);
gBrowser.selectedTab = emptyTab;
setTimeout(() => {
window.addEventListener(
"ZenURLBarClosed",
event => {
const { onElementPicked, onSwitch } = event.detail;
const groupIndex = this._data.findIndex(group =>
group.tabs.includes(emptyTab)
);
const newSelectedTab = gBrowser.selectedTab;
const cleanup = () => {
this.removeTabFromGroup(emptyTab, groupIndex, {
changeTab: !onSwitch,
forUnsplit: true,
});
const command = document.getElementById("cmd_zenNewEmptySplit");
command.removeAttribute("disabled");
};
if (onElementPicked) {
if (
newSelectedTab === emptyTab ||
newSelectedTab === selectedTab ||
selectedTab.getAttribute("zen-workspace-id") !==
newSelectedTab.getAttribute("zen-workspace-id")
) {
cleanup();
return;
}
this.removeTabFromGroup(emptyTab, groupIndex, {
forUnsplit: true,
});
gBrowser.selectedTab = selectedTab;
this.resetTabState(emptyTab, false);
this.splitTabs(
topOrLeft
? [newSelectedTab, selectedTab]
: [selectedTab, newSelectedTab],
gridType,
topOrLeft ? 0 : 1
);
} else {
cleanup();
}
},
{ once: true }
);
gZenUIManager.handleNewTab(false, false, "tab", true);
});
});
}
get splitViewBrowsers() {
if (this.currentView < 0) {
return [];
}
return this._data[this.currentView].tabs.map(tab => tab.linkedBrowser);
}
}
window.gZenViewSplitter = new nsZenViewSplitter();