gecko-dev/browser/components/tabbrowser/TabsList.sys.mjs
Stephen Thompson dcd23beba7 Bug 1954346 - support tab group drag/drop in list all tabs menu r=dao,dwalker,tabbrowser-reviewers,extension-reviewers,robwu
The "list all tabs" menu displayed tab groups and allowed drag/dropping tabs around, but an error would be thrown when trying to drag an entire tab group. This patch allows dragging tab groups in the "list all tabs" menu and dropping them elsewhere.

This accounts for a number of edge cases:
- drag tab group to the end of the menu
- drag tab right before a tab group
- drag tab right after a collapsed tab group
- drag an ungrouped tab into the position of the first tab of a tab group

The tab group menu items didn't have a `tab` property like the tab menu items. That property is what gets passed to the drag-drop code to do its work. I made `tab` and `tabGroup` properties available on the `toolbaritem` elements and included some helper functions to access them consistently.

With that in place, tab groups would generally drag and drop OK, but the "list all tabs" menu wouldn't update. The menu needs to be rebuilt when a tab group moves, so I started listening for the `TabGroupMoved` event.

TabsPanel._onDrop was using `_tPos` on the menu item's `tab`, but that isn't present on tab group labels. I tried modifying the logic to use the `elementIndex`, but that led to edge cases with `Tabbrowser.moveTabTo`. Since the TabsPanel was already tracking the drop direction, I switched to using moveTabBefore/moveTabAfter for simplicity.

The TabsListBase._addTab method was still assuming that the tab strip was a linear list of tabs, so it wasn't doing great at handling the scenario of dropping a tab next to a tab group label. The drop could result in 1) no change, 2) tab moves correctly but it becomes the last item of the "list all tabs" menu, 3) console error. I switched to using the <tabs> element's `findNextTab`. I found an edge case where Tabbrowser.moveTabAfter would move a tab into the first position of a collapsed tab group instead of moving the tab after the whole tab group. I updated Tabbrowser.#moveTabNextTo for that case.

I encountered an edge case when dragging a tab in "list all tabs" between two pinned tabs when the first item in the unpinned tab strip is a tab group. The dropped tab was being inserted into the group as the first grouped tab instead of dropping as an ungrouped tab. I added a general fix in Tabbrowser.#moveTabNextTo -- not sure if other code paths would be affected by this bug or just the "list all tabs" menu

When we added tab groups to the "list all tabs" menu in bug 1908431, this had two side effects:
1. the hidden tabs menus would show tab group labels if a hidden tab was in an expanded tab group
2. hidden tabs would NOT show up in the hidden tabs menus if they were in a collapsed tab group

Our team decided that (1) was desirable because it added helpful context to those hidden tabs. (2) is not desirable, so this patch tries to ensure that hidden tabs always appear in the different hidden tabs lists even if they are also in collapsed tab groups.

For consistency with tabs in the "list all tabs" menu and consistency with the tab strip, middle-clicking on a tab group label in the "list all tabs" menu will now save and close the tab group.

Differential Revision: https://phabricator.services.mozilla.com/D247031
2025-06-11 15:21:29 +00:00

921 lines
25 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/. */
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<void>} */
get domRefreshComplete() {
return this.#domRefreshPromise ?? Promise.resolve();
}
/** @type {Promise<void>|undefined} */
#domRefreshPromise;
/** @type {Map<MozTabbrowserTab, XulToolbarItem>} */
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<XulToolbarItem>} */
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 });
}
}
}
}