/* 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/. */ const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { PanelMultiView: "resource:///modules/PanelMultiView.sys.mjs", TabMetrics: "moz-src:///browser/components/tabbrowser/TabMetrics.sys.mjs", }); const TAB_DROP_TYPE = "application/x-moz-tabbrowser-tab"; const ROW_VARIANT_TAB = "tab"; const ROW_VARIANT_TAB_GROUP = "tab-group"; function setAttributes(element, attrs) { for (let [name, value] of Object.entries(attrs)) { if (value) { element.setAttribute(name, value); } else { element.removeAttribute(name); } } } /** * @param {Element} element * One row (`toolbaritem`) of this tab list or one of its descendent * elements, e.g. a `toolbarbutton`. * @returns {MozTabbrowserTab|undefined} */ function getTabFromRow(element) { return element.closest("toolbaritem")?._tab; } /** * @param {Element} element * One row (`toolbaritem`) of this tab list or one of its descendent * elements, e.g. a `toolbarbutton`. * @returns {MozTabbrowserTabGroup|undefined} */ function getTabGroupFromRow(element) { return element.closest("toolbaritem")?._tabGroup; } /** * @param {Element} element * One row (`toolbaritem`) of this tab list or one of its descendent * elements, e.g. a `toolbarbutton`. * @returns {"tab"|"tab-group"|undefined} */ function getRowVariant(element) { return element.closest("toolbaritem")?.getAttribute("row-variant"); } class TabsListBase { /** @returns {Promise} */ get domRefreshComplete() { return this.#domRefreshPromise ?? Promise.resolve(); } /** @type {Promise|undefined} */ #domRefreshPromise; /** @type {Map} */ tabToElement = new Map(); /** * @param {object} opts * @param {string} opts.className * @param {function(MozTabbrowserTab):boolean} opts.filterFn * @param {Element} opts.containerNode * @param {Element} [opts.dropIndicator=null] * @param {boolean} opts.onlyHiddenTabs */ constructor({ className, filterFn, containerNode, dropIndicator = null, onlyHiddenTabs, }) { /** @type {string} */ this.className = className; /** @type {function(MozTabbrowserTab):boolean} */ this.filterFn = onlyHiddenTabs ? tab => filterFn(tab) && tab.hidden : filterFn; /** @type {Element} */ this.containerNode = containerNode; /** @type {Element|null} */ this.dropIndicator = dropIndicator; if (this.dropIndicator) { /** @type {XulToolbarItem|null} */ this.dropTargetRow = null; /** @type {-1|0} */ this.dropTargetDirection = 0; } /** @type {Document} */ this.doc = containerNode.ownerDocument; /** @type {Tabbrowser} */ this.gBrowser = this.doc.defaultView.gBrowser; /** @type {boolean} */ this.listenersRegistered = false; /** @type {boolean} */ this.onlyHiddenTabs = onlyHiddenTabs; } /** @returns {MapIterator} */ get rows() { return this.tabToElement.values(); } handleEvent(event) { switch (event.type) { case "TabAttrModified": this._tabAttrModified(event.target); break; case "TabClose": this._tabClose(event.target); break; case "TabGroupCollapse": case "TabGroupExpand": case "TabGroupCreate": case "TabGroupRemoved": case "TabGrouped": case "TabGroupMoved": case "TabUngrouped": this._refreshDOM(); break; case "TabMove": this._moveTab(event.target); break; case "TabPinned": if (!this.filterFn(event.target)) { this._tabClose(event.target); } break; case "command": this.#handleCommand(event); break; case "dragstart": this._onDragStart(event); break; case "dragover": this._onDragOver(event); break; case "dragleave": this._onDragLeave(event); break; case "dragend": this._onDragEnd(event); break; case "drop": this._onDrop(event); break; case "click": this._onClick(event); break; } } /** * @param {XULCommandEvent} event */ #handleCommand(event) { if (event.target.classList.contains("all-tabs-mute-button")) { getTabFromRow(event.target)?.toggleMuteAudio(); } else if (event.target.classList.contains("all-tabs-close-button")) { const tab = getTabFromRow(event.target); if (tab) { this.gBrowser.removeTab( tab, lazy.TabMetrics.userTriggeredContext( lazy.TabMetrics.METRIC_SOURCE.TAB_OVERFLOW_MENU ) ); } } else { const rowVariant = getRowVariant(event.target); if (rowVariant == ROW_VARIANT_TAB) { const tab = getTabFromRow(event.target); if (tab) { this._selectTab(tab); } } else if (rowVariant == ROW_VARIANT_TAB_GROUP) { getTabGroupFromRow(event.target)?.select(); } } } _selectTab(tab) { if (this.gBrowser.selectedTab != tab) { this.gBrowser.selectedTab = tab; } else { this.gBrowser.tabContainer._handleTabSelect(); } } /* * Populate the popup with menuitems and setup the listeners. */ _populate() { this._populateDOM(); this._setupListeners(); } _populateDOM() { let fragment = this.doc.createDocumentFragment(); let currentGroupId; for (let tab of this.gBrowser.tabs) { if (this.filterFn(tab)) { if (tab.group && tab.group.id != currentGroupId) { fragment.appendChild(this._createGroupRow(tab.group)); currentGroupId = tab.group.id; } if (!tab.group?.collapsed || this.onlyHiddenTabs) { // Don't show tabs in collapsed tab groups in the main tabs list. // However, in the hidden tabs lists, do show hidden tabs even if // they belong to collapsed tab groups. fragment.appendChild(this._createRow(tab)); } } } this._addElement(fragment); } _addElement(elementOrFragment) { this.containerNode.appendChild(elementOrFragment); } /* * Remove the menuitems from the DOM, cleanup internal state and listeners. */ _cleanup() { this._cleanupDOM(); this._cleanupListeners(); this._clearDropTarget(); } _cleanupDOM() { this.containerNode .querySelectorAll(":scope toolbaritem") .forEach(node => node.remove()); this.tabToElement = new Map(); } _refreshDOM() { if (!this.#domRefreshPromise) { this.#domRefreshPromise = new Promise(resolve => { this.containerNode.ownerGlobal.requestAnimationFrame(() => { if (this.#domRefreshPromise) { if (this.listenersRegistered) { // Only re-render the menu DOM if the menu is still open. this._cleanupDOM(); this._populateDOM(); } resolve(); this.#domRefreshPromise = undefined; } }); }); } } _setupListeners() { this.listenersRegistered = true; this.gBrowser.tabContainer.addEventListener("TabAttrModified", this); this.gBrowser.tabContainer.addEventListener("TabClose", this); this.gBrowser.tabContainer.addEventListener("TabMove", this); this.gBrowser.tabContainer.addEventListener("TabPinned", this); this.gBrowser.tabContainer.addEventListener("TabGroupCollapse", this); this.gBrowser.tabContainer.addEventListener("TabGroupExpand", this); this.gBrowser.tabContainer.addEventListener("TabGroupCreate", this); this.gBrowser.tabContainer.addEventListener("TabGroupRemoved", this); this.gBrowser.tabContainer.addEventListener("TabGroupMoved", this); this.gBrowser.tabContainer.addEventListener("TabGrouped", this); this.gBrowser.tabContainer.addEventListener("TabUngrouped", this); this.containerNode.addEventListener("click", this); this.containerNode.addEventListener("command", this); if (this.dropIndicator) { this.containerNode.addEventListener("dragstart", this); this.containerNode.addEventListener("dragover", this); this.containerNode.addEventListener("dragleave", this); this.containerNode.addEventListener("dragend", this); this.containerNode.addEventListener("drop", this); } } _cleanupListeners() { this.gBrowser.tabContainer.removeEventListener("TabAttrModified", this); this.gBrowser.tabContainer.removeEventListener("TabClose", this); this.gBrowser.tabContainer.removeEventListener("TabMove", this); this.gBrowser.tabContainer.removeEventListener("TabPinned", this); this.gBrowser.tabContainer.removeEventListener("TabGroupCollapse", this); this.gBrowser.tabContainer.removeEventListener("TabGroupExpand", this); this.gBrowser.tabContainer.removeEventListener("TabGroupCreate", this); this.gBrowser.tabContainer.removeEventListener("TabGroupRemoved", this); this.gBrowser.tabContainer.removeEventListener("TabGroupMoved", this); this.gBrowser.tabContainer.removeEventListener("TabGrouped", this); this.gBrowser.tabContainer.removeEventListener("TabUngrouped", this); this.containerNode.removeEventListener("click", this); this.containerNode.removeEventListener("command", this); if (this.dropIndicator) { this.containerNode.removeEventListener("dragstart", this); this.containerNode.removeEventListener("dragover", this); this.containerNode.removeEventListener("dragleave", this); this.containerNode.removeEventListener("dragend", this); this.containerNode.removeEventListener("drop", this); } this.listenersRegistered = false; } /** * @param {MozTabbrowserTab} tab */ _tabAttrModified(tab) { let item = this.tabToElement.get(tab); if (item) { if (!this.filterFn(tab)) { // The tab no longer matches our criteria, remove it. this._removeItem(item, tab); } else { this._setRowAttributes(item, tab); } } else if (this.filterFn(tab)) { // The tab now matches our criteria, add a row for it. this._addTab(tab); } } /** * @param {MozTabbrowserTab} tab */ _moveTab(tab) { let item = this.tabToElement.get(tab); if (item) { this._removeItem(item, tab); this._addTab(tab); } } /** * @param {MozTabbrowserTab} tab */ _addTab(newTab) { if (!this.filterFn(newTab)) { return; } if (newTab.group?.collapsed && !this.onlyHiddenTabs) { return; } let newRow = this._createRow(newTab); let nextTab = this.gBrowser.tabContainer.findNextTab(newTab, { filter: this.filterFn, }); if (!nextTab) { // If there's no next tab then append the new row to the end of the menu. this._addElement(newRow); } else if (!newTab.group && nextTab.group) { // newTab should not go right before nextTab because then it would // appear to be inside the tab group; instead, put newTab before // nextTab's tab group's row menu item. // Should be equivalent to `.insertBefore(newRow, nextRow.previousSiblingElement)` // but this is more explicit about inserting before the nextTab's tab group's // row menu item. let nextTabTabGroupRow = this.containerNode.querySelector( `:scope [tab-group-id="${nextTab.group.id}"]` ); this.containerNode.insertBefore(newRow, nextTabTabGroupRow); } else { let nextRow = this.tabToElement.get(nextTab); if (!nextRow) { // If for some reason the next tab has no item in this menu already, // just add this new tab's menu item to the end. this._addElement(newRow); } else { this.containerNode.insertBefore(newRow, nextRow); } } } _tabClose(tab) { let item = this.tabToElement.get(tab); if (item) { this._removeItem(item, tab); } } _removeItem(item, tab) { this.tabToElement.delete(tab); item.remove(); // If removing this grouped tab results in there being no more tabs from // this tab group in the menu list, then also remove the tab group label // menu item. This is only relevant right now in tabs lists that only show // hidden tabs. For the normal tabs list, removing the last tab in a group // will also remove the tab group, which re-renders the whole tabs list // with the side-effect of removing the tab group label menu item. if ( tab.group && !this.tabToElement.keys().some(t => t.group == tab.group) ) { this.containerNode .querySelector(`:scope [tab-group-id="${tab.group.id}"]`) ?.remove(); } } } const TABS_PANEL_EVENTS = { show: "ViewShowing", hide: "PanelMultiViewHidden", }; export class TabsPanel extends TabsListBase { /** * @param {object} opts * @param {string} opts.className * @param {function(MozTabbrowserTab):boolean} opts.filterFn * @param {Element} opts.containerNode * @param {Element} [opts.dropIndicator=null] * @param {Element} opts.view * @param {boolean} opts.onlyHiddenTabs */ constructor(opts) { super({ ...opts, containerNode: opts.containerNode || opts.view.firstElementChild, }); this.view = opts.view; this.view.addEventListener(TABS_PANEL_EVENTS.show, this); this.panelMultiView = null; } handleEvent(event) { switch (event.type) { case TABS_PANEL_EVENTS.hide: if (event.target == this.panelMultiView) { this._cleanup(); this.panelMultiView = null; } break; case TABS_PANEL_EVENTS.show: if (!this.listenersRegistered && event.target == this.view) { this.panelMultiView = this.view.panelMultiView; this._populate(event); this.gBrowser.translateTabContextMenu(); } break; default: super.handleEvent(event); break; } } _populate(event) { super._populate(event); // The loading throbber can't be set until the toolbarbutton is rendered, // so set the image attributes again now that the elements are in the DOM. for (let row of this.rows) { // Ensure this isn't a group label if (getRowVariant(row) == ROW_VARIANT_TAB) { this._setImageAttributes(row, getTabFromRow(row)); } } } _selectTab(tab) { super._selectTab(tab); lazy.PanelMultiView.hidePopup(this.view.closest("panel")); } _setupListeners() { super._setupListeners(); this.panelMultiView.addEventListener(TABS_PANEL_EVENTS.hide, this); } _cleanupListeners() { super._cleanupListeners(); this.panelMultiView.removeEventListener(TABS_PANEL_EVENTS.hide, this); } /** * @param {MozTabbrowserTab} tab * @returns {XULElement} */ _createRow(tab) { let { doc } = this; let row = doc.createXULElement("toolbaritem"); row.setAttribute("class", "all-tabs-item"); if (this.className) { row.classList.add(this.className); } row.setAttribute("context", "tabContextMenu"); row.setAttribute("row-variant", ROW_VARIANT_TAB); /** * Setting a new property `XulToolbarItem._tab` on the row elements * for internal use by this module only. * @see getTabFromRow */ row._tab = tab; this.tabToElement.set(tab, row); let button = doc.createXULElement("toolbarbutton"); button.setAttribute( "class", "all-tabs-button subviewbutton subviewbutton-iconic" ); button.setAttribute("flex", "1"); button.setAttribute("crop", "end"); /** * Setting a new property `MozToolbarbutton.tab` on the buttons * to support tab context menu integration. * @see TabContextMenu.updateContextMenu */ button.tab = tab; if (tab.userContextId) { tab.classList.forEach(property => { if (property.startsWith("identity-color")) { button.classList.add(property); button.classList.add("all-tabs-container-indicator"); } }); } if (tab.group) { row.classList.add("grouped"); } row.appendChild(button); let muteButton = doc.createXULElement("toolbarbutton"); muteButton.classList.add( "all-tabs-mute-button", "all-tabs-secondary-button", "subviewbutton" ); muteButton.setAttribute("closemenu", "none"); row.appendChild(muteButton); if (!tab.pinned) { let closeButton = doc.createXULElement("toolbarbutton"); closeButton.classList.add( "all-tabs-close-button", "all-tabs-secondary-button", "subviewbutton" ); closeButton.setAttribute("closemenu", "none"); doc.l10n.setAttributes(closeButton, "tabbrowser-manager-close-tab"); row.appendChild(closeButton); } this._setRowAttributes(row, tab); return row; } /** * @param {MozTabbrowserTabGroup} group * @returns {XULElement} */ _createGroupRow(group) { let { doc } = this; let row = doc.createXULElement("toolbaritem"); row.setAttribute("class", "all-tabs-item all-tabs-group-item"); row.setAttribute("row-variant", ROW_VARIANT_TAB_GROUP); row.setAttribute("tab-group-id", group.id); /** * Setting a new property `XulToolbarItem._tabGroup` on the row elements * for internal use by this module only. * @see getTabGroupFromRow */ row._tabGroup = group; row.style.setProperty( "--tab-group-color", `var(--tab-group-color-${group.color})` ); row.style.setProperty( "--tab-group-color-invert", `var(--tab-group-color-${group.color}-invert)` ); row.style.setProperty( "--tab-group-color-pale", `var(--tab-group-color-${group.color}-pale)` ); let button = doc.createXULElement("toolbarbutton"); button.setAttribute("context", "open-tab-group-context-menu"); button.classList.add( "all-tabs-button", "all-tabs-group-button", "subviewbutton", "subviewbutton-iconic", group.collapsed ? "tab-group-icon-collapsed" : "tab-group-icon" ); button.setAttribute("flex", "1"); button.setAttribute("crop", "end"); let setName = tabGroupName => { doc.l10n.setAttributes( button, "tabbrowser-manager-current-window-tab-group", { tabGroupName } ); }; if (group.label) { setName(group.label); } else { doc.l10n .formatValues([{ id: "tab-group-name-default" }]) .then(([msg]) => { setName(msg); }); } row.appendChild(button); return row; } /** * @param {XulToolbarItem} row * @param {MozTabbrowserTab} tab */ _setRowAttributes(row, tab) { setAttributes(row, { selected: tab.selected }); let tooltiptext = this.gBrowser.getTabTooltip(tab); let busy = tab.getAttribute("busy"); let button = row.firstElementChild; setAttributes(button, { busy, label: tab.label, tooltiptext, image: !busy && tab.getAttribute("image"), iconloadingprincipal: tab.getAttribute("iconloadingprincipal"), }); this._setImageAttributes(row, tab); let muteButton = row.querySelector(".all-tabs-mute-button"); let muteButtonTooltipString = tab.muted ? "tabbrowser-manager-unmute-tab" : "tabbrowser-manager-mute-tab"; this.doc.l10n.setAttributes(muteButton, muteButtonTooltipString); setAttributes(muteButton, { muted: tab.muted, soundplaying: tab.soundPlaying, hidden: !(tab.muted || tab.soundPlaying), }); } /** * @param {XulToolbarItem} row * @param {MozTabbrowserTab} tab */ _setImageAttributes(row, tab) { let button = row.firstElementChild; let image = button.icon; if (image) { let busy = tab.getAttribute("busy"); let progress = tab.getAttribute("progress"); setAttributes(image, { busy, progress }); if (busy) { image.classList.add("tab-throbber-tabslist"); } else { image.classList.remove("tab-throbber-tabslist"); } } } /** * @param {DragEvent} event */ _onDragStart(event) { const row = this._getTargetRowFromEvent(event); if (!row) { return; } const elementToDrag = getRowVariant(row) == ROW_VARIANT_TAB_GROUP ? getTabGroupFromRow(row).labelElement : getTabFromRow(row); this.gBrowser.tabContainer.startTabDrag(event, elementToDrag, { fromTabList: true, }); } /** * @param {DragEvent} event * @returns {XulToolbarItem|undefined} */ _getTargetRowFromEvent(event) { return event.target.closest("toolbaritem"); } /** * @param {DragEvent} event * @returns {boolean} */ _isMovingTabs(event) { var effects = this.gBrowser.tabContainer.getDropEffectForTabDrag(event); return effects == "move"; } /** * @param {DragEvent} event */ _onDragOver(event) { if (!this._isMovingTabs(event)) { return; } if (!this._updateDropTarget(event)) { return; } event.preventDefault(); event.stopPropagation(); } /** * @param {XulToolbarItem} row * @returns {number} */ _getRowIndex(row) { return Array.prototype.indexOf.call(this.containerNode.children, row); } /** * @param {DragEvent} event */ _onDrop(event) { if (!this._isMovingTabs(event)) { return; } if (!this._updateDropTarget(event)) { return; } event.preventDefault(); event.stopPropagation(); let draggedElement = event.dataTransfer.mozGetDataAt(TAB_DROP_TYPE, 0); let targetElement = getRowVariant(this.dropTargetRow) == ROW_VARIANT_TAB_GROUP ? getTabGroupFromRow(this.dropTargetRow).labelElement : getTabFromRow(this.dropTargetRow); if (draggedElement === targetElement) { this._clearDropTarget(); return; } // NOTE: Given the list is opened only when the window is focused, // we don't have to check `draggedTab.container`. const metricsContext = { isUserTriggered: true, telemetrySource: lazy.TabMetrics.METRIC_SOURCE.TAB_OVERFLOW_MENU, }; if (this.dropTargetDirection == -1) { this.gBrowser.moveTabBefore( draggedElement, targetElement, metricsContext ); } else { this.gBrowser.moveTabAfter(draggedElement, targetElement, metricsContext); } this._clearDropTarget(); } /** * @param {DragEvent} event */ _onDragLeave(event) { if (!this._isMovingTabs(event)) { return; } let target = event.relatedTarget; while (target && target != this.containerNode) { target = target.parentNode; } if (target) { return; } this._clearDropTarget(); } /** * @param {DragEvent} event */ _onDragEnd(event) { if (!this._isMovingTabs(event)) { return; } this._clearDropTarget(); } /** * @param {DragEvent} event * @returns {boolean} */ _updateDropTarget(event) { const row = this._getTargetRowFromEvent(event); if (!row) { return false; } const rect = row.getBoundingClientRect(); const index = this._getRowIndex(row); if (index === -1) { return false; } const threshold = rect.height * 0.5; if (event.clientY < rect.top + threshold) { this._setDropTarget(row, -1); } else { this._setDropTarget(row, 0); } return true; } /** * @param {XulToolbarItem} row * @param {-1|0} direction */ _setDropTarget(row, direction) { this.dropTargetRow = row; this.dropTargetDirection = direction; const holder = this.dropIndicator.parentNode; const holderOffset = holder.getBoundingClientRect().top; // Set top to before/after the target row. let top; if (this.dropTargetDirection === -1) { if (this.dropTargetRow.previousSibling) { const rect = this.dropTargetRow.previousSibling.getBoundingClientRect(); top = rect.top + rect.height; } else { const rect = this.dropTargetRow.getBoundingClientRect(); top = rect.top; } } else { const rect = this.dropTargetRow.getBoundingClientRect(); top = rect.top + rect.height; } // Avoid overflowing the sub view body. const indicatorHeight = 12; const subViewBody = holder.parentNode; const subViewBodyRect = subViewBody.getBoundingClientRect(); top = Math.min(top, subViewBodyRect.bottom - indicatorHeight); this.dropIndicator.style.top = `${top - holderOffset - 12}px`; this.dropIndicator.collapsed = false; } _clearDropTarget() { if (this.dropTargetRow) { this.dropTargetRow = null; } if (this.dropIndicator) { this.dropIndicator.style.top = `0px`; this.dropIndicator.collapsed = true; } } /** * @param {MouseEvent} event */ _onClick(event) { if (event.button == 1) { const row = this._getTargetRowFromEvent(event); if (!row) { return; } const rowVariant = getRowVariant(row); if (rowVariant == ROW_VARIANT_TAB) { const tab = getTabFromRow(row); this.gBrowser.removeTab(tab, { telemetrySource: lazy.TabMetrics.METRIC_SOURCE.TAB_OVERFLOW_MENU, animate: true, }); } else if (rowVariant == ROW_VARIANT_TAB_GROUP) { getTabGroupFromRow(row)?.saveAndClose({ isUserTriggered: true }); } } } }