Source code
Revision control
Copy as Markdown
Other Tools
/* 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
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs",
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) {
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 = lazy.BrowserUtils.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 = lazy.BrowserUtils.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() {
this._window.addEventListener("contextmenu", this, {
mozSystemGroup: true,
});
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
this._window.removeEventListener("contextmenu", this, {
mozSystemGroup: true,
});
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.
* @returns {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);
},
};