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