summaryrefslogtreecommitdiffstats
path: root/browser/components/syncedtabs/TabListView.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/syncedtabs/TabListView.sys.mjs')
-rw-r--r--browser/components/syncedtabs/TabListView.sys.mjs653
1 files changed, 653 insertions, 0 deletions
diff --git a/browser/components/syncedtabs/TabListView.sys.mjs b/browser/components/syncedtabs/TabListView.sys.mjs
new file mode 100644
index 0000000000..b50c2253a8
--- /dev/null
+++ b/browser/components/syncedtabs/TabListView.sys.mjs
@@ -0,0 +1,653 @@
+/* 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, {
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+});
+
+import { getChromeWindow } from "resource:///modules/syncedtabs/util.sys.mjs";
+
+function getContextMenu(window) {
+ return getChromeWindow(window).document.getElementById(
+ "SyncedTabsSidebarContext"
+ );
+}
+
+function getTabsFilterContextMenu(window) {
+ return getChromeWindow(window).document.getElementById(
+ "SyncedTabsSidebarTabsFilterContext"
+ );
+}
+
+/*
+ * TabListView
+ *
+ * Given a state, this object will render the corresponding DOM.
+ * It maintains no state of it's own. It listens for DOM events
+ * and triggers actions that may cause the state to change and
+ * ultimately the view to rerender.
+ */
+export function TabListView(window, props) {
+ this.props = props;
+
+ this._window = window;
+ this._doc = this._window.document;
+
+ this._tabsContainerTemplate = this._doc.getElementById(
+ "tabs-container-template"
+ );
+ this._clientTemplate = this._doc.getElementById("client-template");
+ this._emptyClientTemplate = this._doc.getElementById("empty-client-template");
+ this._tabTemplate = this._doc.getElementById("tab-template");
+ this.tabsFilter = this._doc.querySelector(".tabsFilter");
+
+ this.container = this._doc.createElement("div");
+
+ this._attachFixedListeners();
+
+ this._setupContextMenu();
+}
+
+TabListView.prototype = {
+ render(state) {
+ // Don't rerender anything; just update attributes, e.g. selection
+ if (state.canUpdateAll) {
+ this._update(state);
+ return;
+ }
+ // Rerender the tab list
+ if (state.canUpdateInput) {
+ this._updateSearchBox(state);
+ this._createList(state);
+ return;
+ }
+ // Create the world anew
+ this._create(state);
+ },
+
+ // Create the initial DOM from templates
+ _create(state) {
+ let wrapper = this._doc.importNode(
+ this._tabsContainerTemplate.content,
+ true
+ ).firstElementChild;
+ this._clearChilden();
+ this.container.appendChild(wrapper);
+
+ this.list = this.container.querySelector(".list");
+
+ this._createList(state);
+ this._updateSearchBox(state);
+
+ this._attachListListeners();
+ },
+
+ _createList(state) {
+ this._clearChilden(this.list);
+ for (let client of state.clients) {
+ if (state.filter) {
+ this._renderFilteredClient(client);
+ } else {
+ this._renderClient(client);
+ }
+ }
+ if (this.list.firstElementChild) {
+ const firstTab = this.list.firstElementChild.querySelector(
+ ".item.tab:first-child .item-title"
+ );
+ if (firstTab) {
+ firstTab.setAttribute("tabindex", 2);
+ }
+ }
+ },
+
+ destroy() {
+ this._teardownContextMenu();
+ this.container.remove();
+ },
+
+ _update(state) {
+ this._updateSearchBox(state);
+ for (let client of state.clients) {
+ let clientNode = this._doc.getElementById("item-" + client.id);
+ if (clientNode) {
+ this._updateClient(client, clientNode);
+ }
+
+ client.tabs.forEach((tab, index) => {
+ let tabNode = this._doc.getElementById(
+ "tab-" + client.id + "-" + index
+ );
+ this._updateTab(tab, tabNode, index);
+ });
+ }
+ },
+
+ // Client rows are hidden when the list is filtered
+ _renderFilteredClient(client, filter) {
+ client.tabs.forEach((tab, index) => {
+ let node = this._renderTab(client, tab, index);
+ this.list.appendChild(node);
+ });
+ },
+
+ _updateLastSyncTitle(lastModified, itemNode) {
+ let lastSync = new Date(lastModified);
+ let lastSyncTitle = getChromeWindow(this._window).gSync.formatLastSyncDate(
+ lastSync
+ );
+ itemNode.setAttribute("title", lastSyncTitle);
+ },
+
+ _renderClient(client) {
+ let itemNode = client.tabs.length
+ ? this._createClient(client)
+ : this._createEmptyClient(client);
+
+ itemNode.addEventListener("mouseover", () =>
+ this._updateLastSyncTitle(client.lastModified, itemNode)
+ );
+
+ this._updateClient(client, itemNode);
+
+ let tabsList = itemNode.querySelector(".item-tabs-list");
+ client.tabs.forEach((tab, index) => {
+ let node = this._renderTab(client, tab, index);
+ tabsList.appendChild(node);
+ });
+
+ this.list.appendChild(itemNode);
+ return itemNode;
+ },
+
+ _renderTab(client, tab, index) {
+ let itemNode = this._createTab(tab);
+ this._updateTab(tab, itemNode, index);
+ return itemNode;
+ },
+
+ _createClient() {
+ return this._doc.importNode(this._clientTemplate.content, true)
+ .firstElementChild;
+ },
+
+ _createEmptyClient() {
+ return this._doc.importNode(this._emptyClientTemplate.content, true)
+ .firstElementChild;
+ },
+
+ _createTab() {
+ return this._doc.importNode(this._tabTemplate.content, true)
+ .firstElementChild;
+ },
+
+ _clearChilden(node) {
+ let parent = node || this.container;
+ while (parent.firstChild) {
+ parent.firstChild.remove();
+ }
+ },
+
+ // These listeners are attached only once, when we initialize the view
+ _attachFixedListeners() {
+ this.tabsFilter.addEventListener("command", this.onFilter.bind(this));
+ this.tabsFilter.addEventListener("focus", this.onFilterFocus.bind(this));
+ this.tabsFilter.addEventListener("blur", this.onFilterBlur.bind(this));
+ },
+
+ // These listeners have to be re-created every time since we re-create the list
+ _attachListListeners() {
+ this.list.addEventListener("click", this.onClick.bind(this));
+ this.list.addEventListener("mouseup", this.onMouseUp.bind(this));
+ this.list.addEventListener("keydown", this.onKeyDown.bind(this));
+ },
+
+ _updateSearchBox(state) {
+ this.tabsFilter.value = state.filter;
+ if (state.inputFocused) {
+ this.tabsFilter.focus();
+ }
+ },
+
+ /**
+ * Update the element representing an item, ensuring it's in sync with the
+ * underlying data.
+ * @param {client} item - Item to use as a source.
+ * @param {Element} itemNode - Element to update.
+ */
+ _updateClient(item, itemNode) {
+ itemNode.setAttribute("id", "item-" + item.id);
+ this._updateLastSyncTitle(item.lastModified, itemNode);
+ if (item.closed) {
+ itemNode.classList.add("closed");
+ } else {
+ itemNode.classList.remove("closed");
+ }
+ if (item.selected) {
+ itemNode.classList.add("selected");
+ } else {
+ itemNode.classList.remove("selected");
+ }
+ if (item.focused) {
+ itemNode.focus();
+ }
+ itemNode.setAttribute("clientType", item.clientType);
+ itemNode.dataset.id = item.id;
+ itemNode.querySelector(".item-title").textContent = item.name;
+ },
+
+ /**
+ * Update the element representing a tab, ensuring it's in sync with the
+ * underlying data.
+ * @param {tab} item - Item to use as a source.
+ * @param {Element} itemNode - Element to update.
+ */
+ _updateTab(item, itemNode, index) {
+ itemNode.setAttribute("title", `${item.title}\n${item.url}`);
+ itemNode.setAttribute("id", "tab-" + item.client + "-" + index);
+ if (item.selected) {
+ itemNode.classList.add("selected");
+ } else {
+ itemNode.classList.remove("selected");
+ }
+ if (item.focused) {
+ itemNode.focus();
+ }
+ itemNode.dataset.url = item.url;
+
+ itemNode.querySelector(".item-title").textContent = item.title;
+
+ if (item.icon) {
+ let icon = itemNode.querySelector(".item-icon-container");
+ icon.style.backgroundImage = "url(" + item.icon + ")";
+ }
+ },
+
+ onMouseUp(event) {
+ if (event.which == 2) {
+ // Middle click
+ this.onClick(event);
+ }
+ },
+
+ onClick(event) {
+ let itemNode = this._findParentItemNode(event.target);
+ if (!itemNode) {
+ return;
+ }
+
+ if (itemNode.classList.contains("tab")) {
+ let url = itemNode.dataset.url;
+ if (url) {
+ this.onOpenSelected(url, event);
+ }
+ }
+
+ // Middle click on a client
+ if (itemNode.classList.contains("client")) {
+ let where = getChromeWindow(this._window).whereToOpenLink(event);
+ if (where != "current") {
+ this._openAllClientTabs(itemNode, where);
+ }
+ }
+
+ if (
+ event.target.classList.contains("item-twisty-container") &&
+ event.which != 2
+ ) {
+ this.props.onToggleBranch(itemNode.dataset.id);
+ return;
+ }
+
+ let position = this._getSelectionPosition(itemNode);
+ this.props.onSelectRow(position);
+ },
+
+ /**
+ * Handle a keydown event on the list box.
+ * @param {Event} event - Triggering event.
+ */
+ onKeyDown(event) {
+ if (event.keyCode == this._window.KeyEvent.DOM_VK_DOWN) {
+ event.preventDefault();
+ this.props.onMoveSelectionDown();
+ } else if (event.keyCode == this._window.KeyEvent.DOM_VK_UP) {
+ event.preventDefault();
+ this.props.onMoveSelectionUp();
+ } else if (event.keyCode == this._window.KeyEvent.DOM_VK_RETURN) {
+ let selectedNode = this.container.querySelector(".item.selected");
+ if (selectedNode.dataset.url) {
+ this.onOpenSelected(selectedNode.dataset.url, event);
+ } else if (selectedNode) {
+ this.props.onToggleBranch(selectedNode.dataset.id);
+ }
+ }
+ },
+
+ onBookmarkTab() {
+ let item = this._getSelectedTabNode();
+ if (item) {
+ let title = item.querySelector(".item-title").textContent;
+ this.props.onBookmarkTab(item.dataset.url, title);
+ }
+ },
+
+ onCopyTabLocation() {
+ let item = this._getSelectedTabNode();
+ if (item) {
+ this.props.onCopyTabLocation(item.dataset.url);
+ }
+ },
+
+ onOpenSelected(url, event) {
+ let where = getChromeWindow(this._window).whereToOpenLink(event);
+ this.props.onOpenTab(url, where, {});
+ },
+
+ onOpenSelectedFromContextMenu(event) {
+ let item = this._getSelectedTabNode();
+ if (item) {
+ let where = event.target.getAttribute("where");
+ let params = {
+ private: event.target.hasAttribute("private"),
+ };
+ this.props.onOpenTab(item.dataset.url, where, params);
+ }
+ },
+
+ onOpenSelectedInContainerTab(event) {
+ let item = this._getSelectedTabNode();
+ if (item) {
+ this.props.onOpenTab(item.dataset.url, "tab", {
+ userContextId: parseInt(event.target?.dataset.usercontextid),
+ });
+ }
+ },
+
+ onOpenAllInTabs() {
+ let item = this._getSelectedClientNode();
+ if (item) {
+ this._openAllClientTabs(item, "tab");
+ }
+ },
+
+ onFilter(event) {
+ let query = event.target.value;
+ if (query) {
+ this.props.onFilter(query);
+ } else {
+ this.props.onClearFilter();
+ }
+ },
+
+ onFilterFocus() {
+ this.props.onFilterFocus();
+ },
+ onFilterBlur() {
+ this.props.onFilterBlur();
+ },
+
+ _getSelectedTabNode() {
+ let item = this.container.querySelector(".item.selected");
+ if (this._isTab(item) && item.dataset.url) {
+ return item;
+ }
+ return null;
+ },
+
+ _getSelectedClientNode() {
+ let item = this.container.querySelector(".item.selected");
+ if (this._isClient(item)) {
+ return item;
+ }
+ return null;
+ },
+
+ // Set up the custom context menu
+ _setupContextMenu() {
+ Services.els.addSystemEventListener(
+ this._window,
+ "contextmenu",
+ this,
+ false
+ );
+ for (let getMenu of [getContextMenu, getTabsFilterContextMenu]) {
+ let menu = getMenu(this._window);
+ menu.addEventListener("popupshowing", this, true);
+ menu.addEventListener("command", this, true);
+ }
+ },
+
+ _teardownContextMenu() {
+ // Tear down context menu
+ Services.els.removeSystemEventListener(
+ this._window,
+ "contextmenu",
+ this,
+ false
+ );
+ for (let getMenu of [getContextMenu, getTabsFilterContextMenu]) {
+ let menu = getMenu(this._window);
+ menu.removeEventListener("popupshowing", this, true);
+ menu.removeEventListener("command", this, true);
+ }
+ },
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "contextmenu":
+ this.handleContextMenu(event);
+ break;
+
+ case "popupshowing": {
+ if (
+ event.target.getAttribute("id") ==
+ "SyncedTabsSidebarTabsFilterContext"
+ ) {
+ this.handleTabsFilterContextMenuShown(event);
+ }
+ break;
+ }
+
+ case "command": {
+ let menu = event.target.closest("menupopup");
+ switch (menu.getAttribute("id")) {
+ case "SyncedTabsSidebarContext":
+ this.handleContentContextMenuCommand(event);
+ break;
+
+ case "SyncedTabsOpenSelectedInContainerTabMenu":
+ this.onOpenSelectedInContainerTab(event);
+ break;
+
+ case "SyncedTabsSidebarTabsFilterContext":
+ this.handleTabsFilterContextMenuCommand(event);
+ break;
+ }
+ break;
+ }
+ }
+ },
+
+ handleTabsFilterContextMenuShown(event) {
+ let document = event.target.ownerDocument;
+ let focusedElement = document.commandDispatcher.focusedElement;
+ if (focusedElement != this.tabsFilter.inputField) {
+ this.tabsFilter.focus();
+ }
+ for (let item of event.target.children) {
+ if (!item.hasAttribute("cmd")) {
+ continue;
+ }
+ let command = item.getAttribute("cmd");
+ let controller =
+ document.commandDispatcher.getControllerForCommand(command);
+ if (controller.isCommandEnabled(command)) {
+ item.removeAttribute("disabled");
+ } else {
+ item.setAttribute("disabled", "true");
+ }
+ }
+ },
+
+ handleContentContextMenuCommand(event) {
+ let id = event.target.getAttribute("id");
+ switch (id) {
+ case "syncedTabsOpenSelected":
+ case "syncedTabsOpenSelectedInTab":
+ case "syncedTabsOpenSelectedInWindow":
+ case "syncedTabsOpenSelectedInPrivateWindow":
+ this.onOpenSelectedFromContextMenu(event);
+ break;
+ case "syncedTabsOpenAllInTabs":
+ this.onOpenAllInTabs();
+ break;
+ case "syncedTabsBookmarkSelected":
+ this.onBookmarkTab();
+ break;
+ case "syncedTabsCopySelected":
+ this.onCopyTabLocation();
+ break;
+ case "syncedTabsRefresh":
+ case "syncedTabsRefreshFilter":
+ this.props.onSyncRefresh();
+ break;
+ }
+ },
+
+ handleTabsFilterContextMenuCommand(event) {
+ let command = event.target.getAttribute("cmd");
+ let dispatcher = getChromeWindow(this._window).document.commandDispatcher;
+ let controller =
+ dispatcher.focusedElement.controllers.getControllerForCommand(command);
+ controller.doCommand(command);
+ },
+
+ handleContextMenu(event) {
+ let menu;
+
+ if (event.target == this.tabsFilter) {
+ menu = getTabsFilterContextMenu(this._window);
+ } else {
+ let itemNode = this._findParentItemNode(event.target);
+ if (itemNode) {
+ let position = this._getSelectionPosition(itemNode);
+ this.props.onSelectRow(position);
+ }
+ menu = getContextMenu(this._window);
+ this.adjustContextMenu(menu);
+ }
+
+ menu.openPopupAtScreen(event.screenX, event.screenY, true, event);
+ },
+
+ adjustContextMenu(menu) {
+ let item = this.container.querySelector(".item.selected");
+ let showTabOptions = this._isTab(item);
+
+ let el = menu.firstElementChild;
+
+ while (el) {
+ let show = false;
+ if (showTabOptions) {
+ if (el.getAttribute("id") == "syncedTabsOpenSelectedInPrivateWindow") {
+ show = lazy.PrivateBrowsingUtils.enabled;
+ } else if (
+ el.getAttribute("id") === "syncedTabsOpenSelectedInContainerTab"
+ ) {
+ show =
+ Services.prefs.getBoolPref("privacy.userContext.enabled", false) &&
+ !lazy.PrivateBrowsingUtils.isWindowPrivate(
+ getChromeWindow(this._window)
+ );
+ } else if (
+ el.getAttribute("id") != "syncedTabsOpenAllInTabs" &&
+ el.getAttribute("id") != "syncedTabsManageDevices"
+ ) {
+ show = true;
+ }
+ } else if (el.getAttribute("id") == "syncedTabsOpenAllInTabs") {
+ const tabs = item.querySelectorAll(".item-tabs-list > .item.tab");
+ show = !!tabs.length;
+ } else if (el.getAttribute("id") == "syncedTabsRefresh") {
+ show = true;
+ } else if (el.getAttribute("id") == "syncedTabsManageDevices") {
+ show = true;
+ }
+ el.hidden = !show;
+
+ el = el.nextElementSibling;
+ }
+ },
+
+ /**
+ * Find the parent item element, from a given child element.
+ * @param {Element} node - Child element.
+ * @return {Element} Element for the item, or null if not found.
+ */
+ _findParentItemNode(node) {
+ while (
+ node &&
+ node !== this.list &&
+ node !== this._doc.documentElement &&
+ !node.classList.contains("item")
+ ) {
+ node = node.parentNode;
+ }
+
+ if (node !== this.list && node !== this._doc.documentElement) {
+ return node;
+ }
+
+ return null;
+ },
+
+ _findParentBranchNode(node) {
+ while (
+ node &&
+ !node.classList.contains("list") &&
+ node !== this._doc.documentElement &&
+ !node.parentNode.classList.contains("list")
+ ) {
+ node = node.parentNode;
+ }
+
+ if (node !== this.list && node !== this._doc.documentElement) {
+ return node;
+ }
+
+ return null;
+ },
+
+ _getSelectionPosition(itemNode) {
+ let parent = this._findParentBranchNode(itemNode);
+ let parentPosition = this._indexOfNode(parent.parentNode, parent);
+ let childPosition = -1;
+ // if the node is not a client, find its position within the parent
+ if (parent !== itemNode) {
+ childPosition = this._indexOfNode(itemNode.parentNode, itemNode);
+ }
+ return [parentPosition, childPosition];
+ },
+
+ _indexOfNode(parent, child) {
+ return Array.prototype.indexOf.call(parent.children, child);
+ },
+
+ _isTab(item) {
+ return item && item.classList.contains("tab");
+ },
+
+ _isClient(item) {
+ return item && item.classList.contains("client");
+ },
+
+ _openAllClientTabs(clientNode, where) {
+ const tabs = clientNode.querySelector(".item-tabs-list").children;
+ const urls = [...tabs].map(tab => tab.dataset.url);
+ this.props.onOpenTabs(urls, where);
+ },
+};