gecko-dev/browser/components/syncedtabs/TabListView.js
Brian Grinstead abf1620630 Bug 1479125 - Migrate calls that expect an element to be returned to use element variation firstChild etc to firstElementChild etc;r=Paolo
This allows the JS to work in HTML documents, where whitespace is preserved. In XUL
documents, whitespace is ignored when parsing so text nodes are generally not returned.

The following changes were made, with manual cleanups as necessary (i.e. when firstChild actually
refers to a text node, or when firstChild is used in a loop to empty out an element):

  firstChild->firstElementChild
  lastChild->lastElementChild
  nextSibling->nextElementSibling
  previousSibling->previousElementSibling
  childNodes->children

MozReview-Commit-ID: 95NQ8syBhYw

--HG--
extra : rebase_source : 186d805f7a2a56694dda9032aceac2dfe5424753
2018-08-08 15:22:53 -07:00

608 lines
18 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/. */
"use strict";
ChromeUtils.import("resource://gre/modules/Services.jsm");
ChromeUtils.defineModuleGetter(this, "PrivateBrowsingUtils",
"resource://gre/modules/PrivateBrowsingUtils.jsm");
let { getChromeWindow } = ChromeUtils.import("resource:///modules/syncedtabs/util.js", {});
let log = ChromeUtils.import("resource://gre/modules/Log.jsm", {})
.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.clearFilter = this._doc.querySelector(".textbox-search-clear");
this.searchBox = this._doc.querySelector(".search-box");
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("input", this.onFilter.bind(this));
this.tabsFilter.addEventListener("focus", this.onFilterFocus.bind(this));
this.tabsFilter.addEventListener("blur", this.onFilterBlur.bind(this));
this.clearFilter.addEventListener("click", this.onClearFilter.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) {
if (state.filter) {
this.searchBox.classList.add("filtered");
} else {
this.searchBox.classList.remove("filtered");
}
this.tabsFilter.value = state.filter;
if (state.inputFocused) {
this.searchBox.setAttribute("focused", true);
this.tabsFilter.focus();
} else {
this.searchBox.removeAttribute("focused");
}
},
/**
* 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);
}
},
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();
}
},
onClearFilter() {
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 "SyncedTabsSidebarTabsFilterContext":
this.handleTabsFilterContextMenuCommand(event);
break;
}
break;
}
}
},
handleTabsFilterContextMenuShown(event) {
let document = event.target.ownerDocument;
let focusedElement = document.commandDispatcher.focusedElement;
if (focusedElement != this.tabsFilter) {
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 = PrivateBrowsingUtils.enabled;
} 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 > 0;
} 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);
}
};