/* 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, { PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", }); let { getChromeWindow } = ChromeUtils.import( "resource:///modules/syncedtabs/util.js" ); let log = ChromeUtils.importESModule( "resource://gre/modules/Log.sys.mjs" ).Log.repository.getLogger("Sync.RemoteTabs"); var EXPORTED_SYMBOLS = ["TabListView"]; 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. */ 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); }, };