* Update logic in replaceTab in tabsplitview.js that changes how we're inserting a replacement tab Differential Revision: https://phabricator.services.mozilla.com/D301125
493 lines
14 KiB
JavaScript
493 lines
14 KiB
JavaScript
/* 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/. */
|
|
|
|
"use strict";
|
|
|
|
// This is loaded into chrome windows with the subscript loader. Wrap in
|
|
// a block to prevent accidentally leaking globals onto `window`.
|
|
{
|
|
ChromeUtils.defineESModuleGetters(this, {
|
|
DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
|
|
});
|
|
|
|
/**
|
|
* A shared task which updates the urlbar indicator whenever:
|
|
* - A split view is activated or deactivated.
|
|
* - The active tab of a split view changes.
|
|
* - The order of tabs in a split view changes.
|
|
*
|
|
* @type {DeferredTask}
|
|
*/
|
|
const updateUrlbarButton = new DeferredTask(() => {
|
|
const { activeSplitView, selectedTab } = gBrowser;
|
|
const button = document.getElementById("split-view-button");
|
|
if (activeSplitView) {
|
|
const activeIndex = activeSplitView.tabs.indexOf(selectedTab);
|
|
button.hidden = false;
|
|
button.setAttribute("data-active-index", activeIndex);
|
|
} else {
|
|
button.hidden = true;
|
|
button.removeAttribute("data-active-index");
|
|
}
|
|
}, 0);
|
|
|
|
class MozTabSplitViewWrapper extends MozXULElement {
|
|
/** @type {MutationObserver} */
|
|
#tabChangeObserver;
|
|
|
|
/** @type {MozTabbrowserTab[]} */
|
|
#tabs = [];
|
|
|
|
#isClosing = false;
|
|
|
|
#isUnsplitting = false;
|
|
|
|
#shouldMoveAllTabsAtOnce = true;
|
|
|
|
#storedPanelWidths = new WeakMap();
|
|
|
|
/**
|
|
* @returns {boolean}
|
|
*/
|
|
get hasActiveTab() {
|
|
return this.hasAttribute("hasactivetab");
|
|
}
|
|
|
|
get shouldMoveAllTabsAtOnce() {
|
|
return this.#shouldMoveAllTabsAtOnce;
|
|
}
|
|
|
|
/**
|
|
* @returns {MozTabbrowserGroup}
|
|
*/
|
|
get group() {
|
|
return gBrowser.isTabGroup(this.parentElement)
|
|
? this.parentElement
|
|
: null;
|
|
}
|
|
|
|
/**
|
|
* @typedef {object} TabSplitViewStateData
|
|
* State of a tab group inside of an open window.
|
|
* @property {number} id
|
|
* Unique ID of the tab splitview.
|
|
* @property {number} numberOfTabs
|
|
* Number of expected tabs in the splitview.
|
|
*
|
|
* Collect data related to a single tab splitview, synchronously.
|
|
*
|
|
* @returns {TabSplitViewStateData}
|
|
* Serialized splitview data
|
|
*/
|
|
get state() {
|
|
return {
|
|
id: this.splitViewId,
|
|
numberOfTabs: this.tabs.length,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param {boolean} val
|
|
*/
|
|
set hasActiveTab(val) {
|
|
this.toggleAttribute("hasactivetab", val);
|
|
}
|
|
|
|
get multiselected() {
|
|
return this.hasAttribute("multiselected");
|
|
}
|
|
|
|
constructor() {
|
|
super();
|
|
XPCOMUtils.defineLazyPreferenceGetter(
|
|
this,
|
|
"_hasUsedSplitView",
|
|
"browser.tabs.splitview.hasUsed",
|
|
false
|
|
);
|
|
}
|
|
|
|
connectedCallback() {
|
|
// Set up TabSelect listener, as this gets
|
|
// removed in disconnectedCallback
|
|
this.documentGlobal.addEventListener("TabSelect", this);
|
|
|
|
this.#observeTabChanges();
|
|
this.#restorePanelWidths();
|
|
|
|
if (this.hasActiveTab) {
|
|
this.#activate();
|
|
}
|
|
|
|
if (this._initialized) {
|
|
return;
|
|
}
|
|
|
|
if (!this._hasUsedSplitView) {
|
|
Services.prefs.setBoolPref("browser.tabs.splitview.hasUsed", true);
|
|
}
|
|
|
|
this._initialized = true;
|
|
|
|
this.textContent = "";
|
|
|
|
// Mirroring MozTabbrowserTab
|
|
this.container = gBrowser.tabContainer;
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
this.#tabChangeObserver?.disconnect();
|
|
this.documentGlobal.removeEventListener("TabSelect", this);
|
|
this.#deactivate();
|
|
this.#resetPanelWidths();
|
|
this.container.dispatchEvent(
|
|
new CustomEvent("SplitViewRemoved", {
|
|
bubbles: true,
|
|
composed: true,
|
|
})
|
|
);
|
|
}
|
|
|
|
#observeTabChanges() {
|
|
if (!this.#tabChangeObserver) {
|
|
this.#tabChangeObserver = new window.MutationObserver(mutations => {
|
|
if (this.tabs.length) {
|
|
this.hasActiveTab = this.tabs.some(tab => tab.selected);
|
|
this.tabs.forEach((tab, index) => {
|
|
// Renumber tabs so that a11y tools can tell users that a given
|
|
// tab is "1 of 2" in the split view, for example.
|
|
tab.setAttribute("aria-posinset", index + 1);
|
|
tab.setAttribute("aria-setsize", this.tabs.length);
|
|
tab.updateSplitViewAriaLabel(index);
|
|
});
|
|
this.dispatchEvent(
|
|
new CustomEvent("SplitViewTabChange", {
|
|
bubbles: true,
|
|
})
|
|
);
|
|
} else {
|
|
this.remove();
|
|
}
|
|
|
|
if (
|
|
this.tabs.length == 1 &&
|
|
mutations.length &&
|
|
mutations[0].removedNodes.length == 1
|
|
) {
|
|
// We assume you end up with only one tab in a splitview when the other tab is closed,
|
|
// in which case, move the remaining tab out via this.unsplitTabs.
|
|
this.unsplitTabs("tab_close");
|
|
}
|
|
});
|
|
}
|
|
this.#tabChangeObserver.observe(this, {
|
|
childList: true,
|
|
});
|
|
}
|
|
|
|
get splitViewId() {
|
|
return parseInt(this.getAttribute("splitViewId"));
|
|
}
|
|
|
|
set splitViewId(val) {
|
|
this.setAttribute("splitViewId", val);
|
|
}
|
|
|
|
/**
|
|
* @returns {MozTabbrowserTab[]}
|
|
*/
|
|
get tabs() {
|
|
return Array.from(this.children).filter(node => node.matches("tab"));
|
|
}
|
|
|
|
get visible() {
|
|
return this.tabs.every(tab => tab.visible);
|
|
}
|
|
|
|
get pinned() {
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Get the list of tab panels from this split view.
|
|
*
|
|
* @returns {XULElement[]}
|
|
*/
|
|
get panels() {
|
|
const panels = [];
|
|
for (const { linkedPanel } of this.#tabs) {
|
|
const el = document.getElementById(linkedPanel);
|
|
if (el) {
|
|
panels.push(el);
|
|
}
|
|
}
|
|
return panels;
|
|
}
|
|
|
|
/**
|
|
* Show all Split View tabs in the content area.
|
|
*/
|
|
#activate() {
|
|
updateUrlbarButton.arm();
|
|
gBrowser.showSplitViewPanels(this.#tabs);
|
|
this.container.dispatchEvent(
|
|
new CustomEvent("TabSplitViewActivate", {
|
|
detail: { tabs: this.#tabs, splitview: this },
|
|
bubbles: true,
|
|
})
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Remove Split View tabs from the content area, which in turn resets
|
|
* various attributes on tabpanels.
|
|
*/
|
|
#deactivate() {
|
|
gBrowser.tabpanels.removeTabsFromSplitview(
|
|
this.#tabs.filter(tab => !tab.splitview || tab.splitview === this)
|
|
);
|
|
updateUrlbarButton.arm();
|
|
this.container.dispatchEvent(
|
|
new CustomEvent("TabSplitViewDeactivate", {
|
|
detail: { tabs: this.#tabs, splitview: this },
|
|
bubbles: true,
|
|
})
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Temporarily hide Split View panels when switching to a non-split-view tab,
|
|
* preserving split-view panel attributes so panels re-enter the flex layout at
|
|
* their correct size on reactivation, avoiding content reflow inside split-view
|
|
* browser elements.
|
|
*/
|
|
#suspend() {
|
|
gBrowser.tabpanels.suspendSplitViewPanels(
|
|
this.#tabs.filter(tab => !tab.splitview || tab.splitview === this)
|
|
);
|
|
updateUrlbarButton.arm();
|
|
this.container.dispatchEvent(
|
|
new CustomEvent("TabSplitViewDeactivate", {
|
|
detail: { tabs: this.#tabs, splitview: this },
|
|
bubbles: true,
|
|
})
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Remove customized panel widths. Cache width values so that they can be
|
|
* restored if this Split View is later reactivated.
|
|
*/
|
|
#resetPanelWidths() {
|
|
for (const panel of this.panels) {
|
|
const width = panel.getAttribute("width");
|
|
if (width) {
|
|
this.#storedPanelWidths.set(panel, width);
|
|
panel.removeAttribute("width");
|
|
panel.style.removeProperty("width");
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resize panel widths back to cached values.
|
|
*/
|
|
#restorePanelWidths() {
|
|
for (const panel of this.panels) {
|
|
const width = this.#storedPanelWidths.get(panel);
|
|
if (width) {
|
|
panel.setAttribute("width", width);
|
|
panel.style.setProperty("width", width + "px");
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reset custom width on the right panel, allowing it to fill the rest of
|
|
* the available space.
|
|
*/
|
|
resetRightPanelWidth() {
|
|
const panel = this.panels[1];
|
|
this.#storedPanelWidths.delete(panel);
|
|
panel.removeAttribute("width");
|
|
panel.style.removeProperty("width");
|
|
}
|
|
|
|
/**
|
|
* add tabs to the split view wrapper
|
|
*
|
|
* @param {MozTabbrowserTab[]} tabs
|
|
* @param {object} [options]
|
|
* @param {boolean} [options.isSessionRestore]
|
|
* @param {int} [options.indexOfReplacedTab] [optional] Used if replacing a tab in the split view
|
|
*/
|
|
addTabs(tabs, { isSessionRestore = false, indexOfReplacedTab = -1 } = {}) {
|
|
for (let tab of tabs) {
|
|
if (tab.pinned) {
|
|
return;
|
|
}
|
|
let tabToMove =
|
|
this.documentGlobal === tab.documentGlobal
|
|
? tab
|
|
: gBrowser.adoptTab(tab, {
|
|
tabIndex: gBrowser.tabs.at(-1)._tPos + 1,
|
|
selectTab: tab.selected,
|
|
});
|
|
if (indexOfReplacedTab > -1 && indexOfReplacedTab < this.#tabs.length) {
|
|
this.#tabs[indexOfReplacedTab] = tabToMove;
|
|
} else {
|
|
this.#tabs.push(tabToMove);
|
|
}
|
|
isSessionRestore
|
|
? this.appendChild(tab)
|
|
: gBrowser.moveTabToSplitView(tabToMove, this, indexOfReplacedTab);
|
|
if (tab === gBrowser.selectedTab) {
|
|
this.hasActiveTab = true;
|
|
}
|
|
}
|
|
|
|
if (this.hasActiveTab) {
|
|
this.#activate();
|
|
}
|
|
// Attempt to update uriCount metric using the resulting tabs collection,
|
|
// as tabs may not be added to the splitview if they are pinned etc.
|
|
for (let tab of this.tabs) {
|
|
let tabURI = tab.linkedBrowser.currentURI.spec;
|
|
if (!isBlankPageURL(tabURI) && tabURI !== "about:opentabs") {
|
|
// Add to the counter which tracks the number of URIs loaded into splitview tabs
|
|
const index = tabs.indexOf(tab);
|
|
const label = String(index + 1); // 0 -> "1" (LTR left), 1 -> "2" (LTR right)
|
|
Glean.splitview.uriCount[label].add(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Close about:openTabs and move all tabs out of the split view wrapper;
|
|
* splitview removal is handled by #observeTabChanges.
|
|
*
|
|
* @param {string} [trigger]
|
|
* The trigger method for ending the split view. Used for telemetry.
|
|
* Valid values: "menu_separate", "icon_separate", "icon_close", "tab_close", "footer_separate".
|
|
*/
|
|
unsplitTabs(trigger = null) {
|
|
if (this.#isUnsplitting) {
|
|
return;
|
|
}
|
|
this.#isUnsplitting = true;
|
|
let telemetryTrigger = this.#isClosing ? null : trigger;
|
|
// Record telemetry for split view end
|
|
if (telemetryTrigger) {
|
|
const tab_layout = gBrowser.tabContainer.verticalMode
|
|
? "vertical"
|
|
: "horizontal";
|
|
|
|
Glean.splitview.end.record({
|
|
tab_layout,
|
|
trigger: telemetryTrigger,
|
|
});
|
|
}
|
|
|
|
// If the split view has about:opentabs open, remove that tab
|
|
let aboutOpenTabs = this.#tabs.filter(
|
|
tab => tab?.linkedBrowser?.currentURI?.spec === "about:opentabs"
|
|
);
|
|
aboutOpenTabs.forEach(aboutOpenTab => {
|
|
gBrowser.removeTab(aboutOpenTab);
|
|
});
|
|
|
|
for (let i = this.tabs.length - 1; i >= 0; i--) {
|
|
gBrowser.handleTabMove(this.tabs[i], () =>
|
|
gBrowser.tabContainer.insertBefore(
|
|
this.tabs[i],
|
|
this.nextElementSibling
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Replace a tab in the split view with another tab
|
|
*/
|
|
replaceTab(tabToReplace, newTab) {
|
|
let indexOfReplacedTab = this.tabs.indexOf(tabToReplace);
|
|
|
|
// Select the adopted tab BEFORE removing the old one to prevent Firefox
|
|
// from auto-selecting the wrong tab when the old selected tab is removed.
|
|
if (tabToReplace.selected) {
|
|
gBrowser.selectedTab = newTab;
|
|
}
|
|
gBrowser.removeTab(tabToReplace);
|
|
this.addTabs([newTab], { isSessionRestore: false, indexOfReplacedTab });
|
|
|
|
// We need to re-activate after removing one of the split view tabs
|
|
this.#activate();
|
|
}
|
|
|
|
/**
|
|
* Reverse order of the tabs in the split view wrapper.
|
|
*
|
|
* @param {string} [trigger]
|
|
* The trigger method for reversing tabs. Used for telemetry.
|
|
*/
|
|
reverseTabs(trigger = null) {
|
|
const [firstTab, secondTab] = this.#tabs;
|
|
this.#shouldMoveAllTabsAtOnce = false;
|
|
gBrowser.moveTabBefore(secondTab, firstTab);
|
|
this.#shouldMoveAllTabsAtOnce = true;
|
|
this.#tabs = [secondTab, firstTab];
|
|
if (this.hasActiveTab) {
|
|
gBrowser.showSplitViewPanels(this.#tabs);
|
|
updateUrlbarButton.arm();
|
|
}
|
|
|
|
// Record telemetry
|
|
if (trigger) {
|
|
Glean.splitview.reverse.record({ trigger });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Close all tabs in the split view wrapper and delete the split view.
|
|
*
|
|
* @param {string} [trigger]
|
|
* The trigger method for ending the split view. Used for telemetry.
|
|
*/
|
|
close(trigger = null) {
|
|
// Record telemetry before closing
|
|
if (trigger) {
|
|
const tab_layout = gBrowser.tabContainer.verticalMode
|
|
? "vertical"
|
|
: "horizontal";
|
|
Glean.splitview.end.record({
|
|
tab_layout,
|
|
trigger,
|
|
});
|
|
}
|
|
this.#isClosing = true;
|
|
gBrowser.removeTabs(this.#tabs);
|
|
}
|
|
|
|
/**
|
|
* @param {CustomEvent} event
|
|
*/
|
|
on_TabSelect(event) {
|
|
const wasActive = this.hasActiveTab;
|
|
this.hasActiveTab = event.target.splitview === this;
|
|
if (this.hasActiveTab) {
|
|
this.#activate();
|
|
// This check ensures we don't call suspend for every tab selection
|
|
// or for a selected tab in a splitview that is being dragged to another window,
|
|
// as this event fires as part of updateCurrentBrowser; we
|
|
// utilize this temporary property - removedByAdoption -
|
|
// that is added in adoptSplitView.
|
|
} else if (wasActive && !event.detail.previousTab?.removedByAdoption) {
|
|
this.#suspend();
|
|
}
|
|
}
|
|
}
|
|
|
|
customElements.define("tab-split-view-wrapper", MozTabSplitViewWrapper);
|
|
}
|