summaryrefslogtreecommitdiffstats
path: root/browser/modules/TabsList.jsm
diff options
context:
space:
mode:
Diffstat (limited to 'browser/modules/TabsList.jsm')
-rw-r--r--browser/modules/TabsList.jsm566
1 files changed, 566 insertions, 0 deletions
diff --git a/browser/modules/TabsList.jsm b/browser/modules/TabsList.jsm
new file mode 100644
index 0000000000..a09f58195a
--- /dev/null
+++ b/browser/modules/TabsList.jsm
@@ -0,0 +1,566 @@
+/* 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";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ PanelMultiView: "resource:///modules/PanelMultiView.sys.mjs",
+});
+
+var EXPORTED_SYMBOLS = ["TabsPanel"];
+
+const TAB_DROP_TYPE = "application/x-moz-tabbrowser-tab";
+
+function setAttributes(element, attrs) {
+ for (let [name, value] of Object.entries(attrs)) {
+ if (value) {
+ element.setAttribute(name, value);
+ } else {
+ element.removeAttribute(name);
+ }
+ }
+}
+
+class TabsListBase {
+ constructor({
+ className,
+ filterFn,
+ insertBefore,
+ containerNode,
+ dropIndicator = null,
+ }) {
+ this.className = className;
+ this.filterFn = filterFn;
+ this.insertBefore = insertBefore;
+ this.containerNode = containerNode;
+ this.dropIndicator = dropIndicator;
+
+ if (this.dropIndicator) {
+ this.dropTargetRow = null;
+ this.dropTargetDirection = 0;
+ }
+
+ this.doc = containerNode.ownerDocument;
+ this.gBrowser = this.doc.defaultView.gBrowser;
+ this.tabToElement = new Map();
+ this.listenersRegistered = false;
+ }
+
+ 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 "TabMove":
+ this._moveTab(event.target);
+ break;
+ case "TabPinned":
+ if (!this.filterFn(event.target)) {
+ this._tabClose(event.target);
+ }
+ break;
+ case "command":
+ this._selectTab(event.target.tab);
+ 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;
+ }
+ }
+
+ _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(event) {
+ let fragment = this.doc.createDocumentFragment();
+
+ for (let tab of this.gBrowser.tabs) {
+ if (this.filterFn(tab)) {
+ fragment.appendChild(this._createRow(tab));
+ }
+ }
+
+ this._addElement(fragment);
+ this._setupListeners();
+ }
+
+ _addElement(elementOrFragment) {
+ this.containerNode.insertBefore(elementOrFragment, this.insertBefore);
+ }
+
+ /*
+ * Remove the menuitems from the DOM, cleanup internal state and listeners.
+ */
+ _cleanup() {
+ for (let item of this.rows) {
+ item.remove();
+ }
+ this.tabToElement = new Map();
+ this._cleanupListeners();
+ this._clearDropTarget();
+ }
+
+ _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.containerNode.addEventListener("click", 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.containerNode.removeEventListener("click", 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;
+ }
+
+ _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);
+ }
+ }
+
+ _moveTab(tab) {
+ let item = this.tabToElement.get(tab);
+ if (item) {
+ this._removeItem(item, tab);
+ this._addTab(tab);
+ }
+ }
+ _addTab(newTab) {
+ if (!this.filterFn(newTab)) {
+ return;
+ }
+ let newRow = this._createRow(newTab);
+ let nextTab = newTab.nextElementSibling;
+
+ while (nextTab && !this.filterFn(nextTab)) {
+ nextTab = nextTab.nextElementSibling;
+ }
+
+ // If we found a tab after this one in the list, insert the new row before it.
+ let nextRow = this.tabToElement.get(nextTab);
+ if (nextRow) {
+ nextRow.parentNode.insertBefore(newRow, nextRow);
+ } else {
+ // If there's no next tab then insert it as usual.
+ this._addElement(newRow);
+ }
+ }
+ _tabClose(tab) {
+ let item = this.tabToElement.get(tab);
+ if (item) {
+ this._removeItem(item, tab);
+ }
+ }
+
+ _removeItem(item, tab) {
+ this.tabToElement.delete(tab);
+ item.remove();
+ }
+}
+
+const TABS_PANEL_EVENTS = {
+ show: "ViewShowing",
+ hide: "PanelMultiViewHidden",
+};
+
+class TabsPanel extends TabsListBase {
+ 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;
+ case "command":
+ if (event.target.classList.contains("all-tabs-mute-button")) {
+ event.target.tab.toggleMuteAudio();
+ break;
+ }
+ if (event.target.classList.contains("all-tabs-close-button")) {
+ this.gBrowser.removeTab(event.target.tab);
+ break;
+ }
+ // fall through
+ 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) {
+ this._setImageAttributes(row, row.tab);
+ }
+ }
+
+ _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);
+ }
+
+ _createRow(tab) {
+ let { doc } = this;
+ let row = doc.createXULElement("toolbaritem");
+ row.setAttribute("class", "all-tabs-item");
+ row.setAttribute("context", "tabContextMenu");
+ if (this.className) {
+ row.classList.add(this.className);
+ }
+ row.tab = tab;
+ row.addEventListener("command", this);
+ 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");
+ button.tab = tab;
+
+ row.appendChild(button);
+
+ let muteButton = doc.createXULElement("toolbarbutton");
+ muteButton.classList.add(
+ "all-tabs-mute-button",
+ "all-tabs-secondary-button",
+ "subviewbutton"
+ );
+ muteButton.setAttribute("closemenu", "none");
+ muteButton.tab = tab;
+ row.appendChild(muteButton);
+
+ 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");
+ closeButton.tab = tab;
+ row.appendChild(closeButton);
+
+ this._setRowAttributes(row, tab);
+
+ return row;
+ }
+
+ _setRowAttributes(row, tab) {
+ setAttributes(row, { selected: tab.selected });
+
+ let busy = tab.getAttribute("busy");
+ let button = row.firstElementChild;
+ setAttributes(button, {
+ busy,
+ label: tab.label,
+ 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),
+ });
+ }
+
+ _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");
+ }
+ }
+ }
+
+ _onDragStart(event) {
+ const row = this._getTargetRowFromEvent(event);
+ if (!row) {
+ return;
+ }
+
+ this.gBrowser.tabContainer.startTabDrag(event, row.firstElementChild.tab, {
+ fromTabList: true,
+ });
+ }
+
+ _getTargetRowFromEvent(event) {
+ return event.target.closest("toolbaritem");
+ }
+
+ _isMovingTabs(event) {
+ var effects = this.gBrowser.tabContainer.getDropEffectForTabDrag(event);
+ return effects == "move";
+ }
+
+ _onDragOver(event) {
+ if (!this._isMovingTabs(event)) {
+ return;
+ }
+
+ if (!this._updateDropTarget(event)) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+ }
+
+ _getRowIndex(row) {
+ return Array.prototype.indexOf.call(this.containerNode.children, row);
+ }
+
+ _onDrop(event) {
+ if (!this._isMovingTabs(event)) {
+ return;
+ }
+
+ if (!this._updateDropTarget(event)) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ let draggedTab = event.dataTransfer.mozGetDataAt(TAB_DROP_TYPE, 0);
+
+ if (draggedTab === this.dropTargetRow.firstElementChild.tab) {
+ this._clearDropTarget();
+ return;
+ }
+
+ const targetTab = this.dropTargetRow.firstElementChild.tab;
+
+ // NOTE: Given the list is opened only when the window is focused,
+ // we don't have to check `draggedTab.container`.
+
+ let pos;
+ if (draggedTab._tPos < targetTab._tPos) {
+ pos = targetTab._tPos + this.dropTargetDirection;
+ } else {
+ pos = targetTab._tPos + this.dropTargetDirection + 1;
+ }
+ this.gBrowser.moveTabTo(draggedTab, pos);
+
+ this._clearDropTarget();
+ }
+
+ _onDragLeave(event) {
+ if (!this._isMovingTabs(event)) {
+ return;
+ }
+
+ let target = event.relatedTarget;
+ while (target && target != this.containerNode) {
+ target = target.parentNode;
+ }
+ if (target) {
+ return;
+ }
+
+ this._clearDropTarget();
+ }
+
+ _onDragEnd(event) {
+ if (!this._isMovingTabs(event)) {
+ return;
+ }
+
+ this._clearDropTarget();
+ }
+
+ _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;
+ }
+
+ _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;
+ }
+ }
+
+ _onClick(event) {
+ if (event.button == 1) {
+ const row = this._getTargetRowFromEvent(event);
+ if (!row) {
+ return;
+ }
+
+ this.gBrowser.removeTab(row.tab, {
+ animate: true,
+ });
+ }
+ }
+}