gecko/browser/components/readinglist/sidebar.js

466 lines
12 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";
const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/Task.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource:///modules/readinglist/ReadingList.jsm");
let log = Cu.import("resource://gre/modules/Log.jsm", {})
.Log.repository.getLogger("readinglist.sidebar");
let RLSidebar = {
/**
* Container element for all list item elements.
* @type {Element}
*/
list: null,
/**
* A promise that's resolved when building the initial list completes.
* @type {Promise}
*/
listPromise: null,
/**
* <template> element used for constructing list item elements.
* @type {Element}
*/
itemTemplate: null,
/**
* Map of ReadingList Item objects, keyed by their ID.
* @type {Map}
*/
itemsById: new Map(),
/**
* Map of list item elements, keyed by their corresponding Item's ID.
* @type {Map}
*/
itemNodesById: new Map(),
/**
* Initialize the sidebar UI.
*/
init() {
log.debug("Initializing");
addEventListener("unload", () => this.uninit());
this.list = document.getElementById("list");
this.emptyListInfo = document.getElementById("emptyListInfo");
this.itemTemplate = document.getElementById("item-template");
this.list.addEventListener("click", event => this.onListClick(event));
this.list.addEventListener("mousemove", event => this.onListMouseMove(event));
this.list.addEventListener("keydown", event => this.onListKeyDown(event), true);
window.addEventListener("message", event => this.onMessage(event));
this.listPromise = this.ensureListItems();
ReadingList.addListener(this);
Services.prefs.setBoolPref("browser.readinglist.sidebarEverOpened", true);
let initEvent = new CustomEvent("Initialized", {bubbles: true});
document.documentElement.dispatchEvent(initEvent);
},
/**
* Un-initialize the sidebar UI.
*/
uninit() {
log.debug("Shutting down");
ReadingList.removeListener(this);
},
/**
* Handle an item being added to the ReadingList.
* TODO: We may not want to show this new item right now.
* TODO: We should guard against the list growing here.
*
* @param {ReadinglistItem} item - Item that was added.
*/
onItemAdded(item, append = false) {
log.trace(`onItemAdded: ${item}`);
let itemNode = document.importNode(this.itemTemplate.content, true).firstElementChild;
this.updateItem(item, itemNode);
// XXX Inserting at the top by default is a temp hack that will stop
// working once we start including items received from sync.
if (append)
this.list.appendChild(itemNode);
else
this.list.insertBefore(itemNode, this.list.firstChild);
this.itemNodesById.set(item.id, itemNode);
this.itemsById.set(item.id, item);
this.emptyListInfo.hidden = true;
},
/**
* Handle an item being deleted from the ReadingList.
* @param {ReadingListItem} item - Item that was deleted.
*/
onItemDeleted(item) {
log.trace(`onItemDeleted: ${item}`);
let itemNode = this.itemNodesById.get(item.id);
itemNode.remove();
this.itemNodesById.delete(item.id);
this.itemsById.delete(item.id);
// TODO: ensureListItems doesn't yet cope with needing to add one item.
//this.ensureListItems();
this.emptyListInfo.hidden = (this.numItems > 0);
},
/**
* Handle an item in the ReadingList having any of its properties changed.
* @param {ReadingListItem} item - Item that was updated.
*/
onItemUpdated(item) {
log.trace(`onItemUpdated: ${item}`);
let itemNode = this.itemNodesById.get(item.id);
if (!itemNode)
return;
this.updateItem(item, itemNode);
},
/**
* Update the element representing an item, ensuring it's in sync with the
* underlying data.
* @param {ReadingListItem} item - Item to use as a source.
* @param {Element} itemNode - Element to update.
*/
updateItem(item, itemNode) {
itemNode.setAttribute("id", "item-" + item.id);
itemNode.setAttribute("title", `${item.title}\n${item.url}`);
itemNode.querySelector(".item-title").textContent = item.title;
let domain = item.uri.spec;
try {
domain = item.uri.host;
}
catch (err) {}
itemNode.querySelector(".item-domain").textContent = domain;
let thumb = itemNode.querySelector(".item-thumb-container");
if (item.preview) {
thumb.style.backgroundImage = "url(" + item.preview + ")";
} else {
thumb.style.removeProperty("background-image");
}
thumb.classList.toggle("preview-available", !!item.preview);
},
/**
* Ensure that the list is populated with the correct items.
*/
ensureListItems: Task.async(function* () {
yield ReadingList.forEachItem(item => {
// TODO: Should be batch inserting via DocumentFragment
try {
this.onItemAdded(item, true);
} catch (e) {
log.warn("Error adding item", e);
}
}, {sort: "addedOn", descending: true});
this.emptyListInfo.hidden = (this.numItems > 0);
}),
/**
* Get the number of items currently displayed in the list.
* @type {number}
*/
get numItems() {
return this.list.childElementCount;
},
/**
* The list item displayed in the current tab.
* @type {Element}
*/
get activeItem() {
return document.querySelector("#list > .item.active");
},
set activeItem(node) {
if (node && node.parentNode != this.list) {
log.error(`Unable to set activeItem to invalid node ${node}`);
return;
}
log.trace(`Setting activeItem: ${node ? node.id : null}`);
if (node && node.classList.contains("active")) {
return;
}
let prevItem = document.querySelector("#list > .item.active");
if (prevItem) {
prevItem.classList.remove("active");
}
if (node) {
node.classList.add("active");
}
let event = new CustomEvent("ActiveItemChanged", {bubbles: true});
this.list.dispatchEvent(event);
},
/**
* The list item selected with the keyboard.
* @type {Element}
*/
get selectedItem() {
return document.querySelector("#list > .item.selected");
},
set selectedItem(node) {
if (node && node.parentNode != this.list) {
log.error(`Unable to set selectedItem to invalid node ${node}`);
return;
}
log.trace(`Setting selectedItem: ${node ? node.id : null}`);
let prevItem = document.querySelector("#list > .item.selected");
if (prevItem) {
prevItem.classList.remove("selected");
}
if (node) {
node.classList.add("selected");
let itemId = this.getItemIdFromNode(node);
this.list.setAttribute("aria-activedescendant", "item-" + itemId);
} else {
this.list.removeAttribute("aria-activedescendant");
}
let event = new CustomEvent("SelectedItemChanged", {bubbles: true});
this.list.dispatchEvent(event);
},
/**
* The index of the currently selected item in the list.
* @type {number}
*/
get selectedIndex() {
for (let i = 0; i < this.numItems; i++) {
let item = this.list.children.item(i);
if (!item) {
break;
}
if (item.classList.contains("selected")) {
return i;
}
}
return -1;
},
set selectedIndex(index) {
log.trace(`Setting selectedIndex: ${index}`);
if (index == -1) {
this.selectedItem = null;
return;
}
let item = this.list.children.item(index);
if (!item) {
log.warn(`Unable to set selectedIndex to invalid index ${index}`);
return;
}
this.selectedItem = item;
},
/**
* Open a given URL. The event is used to determine where it should be opened
* (current tab, new tab, new window).
* @param {string} url - URL to open.
* @param {Event} event - KeyEvent or MouseEvent that triggered this action.
*/
openURL(url, event) {
log.debug(`Opening page ${url}`);
let mainWindow = window.QueryInterface(Ci.nsIInterfaceRequestor)
.getInterface(Ci.nsIWebNavigation)
.QueryInterface(Ci.nsIDocShellTreeItem)
.rootTreeItem
.QueryInterface(Ci.nsIInterfaceRequestor)
.getInterface(Ci.nsIDOMWindow);
let currentUrl = mainWindow.gBrowser.currentURI.spec;
if (currentUrl.startsWith("about:reader"))
url = "about:reader?url=" + encodeURIComponent(url);
mainWindow.openUILink(url, event);
},
/**
* Get the ID of the Item associated with a given list item element.
* @param {element} node - List item element to get an ID for.
* @return {string} Assocated Item ID.
*/
getItemIdFromNode(node) {
let id = node.getAttribute("id");
if (id && id.startsWith("item-")) {
return id.slice(5);
}
return null;
},
/**
* Get the Item associated with a given list item element.
* @param {element} node - List item element to get an Item for.
* @return {string} Associated Item.
*/
getItemFromNode(node) {
let itemId = this.getItemIdFromNode(node);
if (!itemId) {
return null;
}
return this.itemsById.get(itemId);
},
/**
* Open the active item in the list.
* @param {Event} event - Event triggering this.
*/
openActiveItem(event) {
let itemNode = this.activeItem;
if (!itemNode) {
return;
}
let item = this.getItemFromNode(itemNode);
this.openURL(item.url, event);
},
/**
* 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 != document.documentElement &&
!node.classList.contains("item")) {
node = node.parentNode;
}
if (node != this.list && node != document.documentElement) {
return node;
}
return null;
},
/**
* Handle a click event on the list box.
* @param {Event} event - Triggering event.
*/
onListClick(event) {
let itemNode = this.findParentItemNode(event.target);
if (!itemNode)
return;
if (event.target.classList.contains("remove-button")) {
ReadingList.deleteItem(this.getItemFromNode(itemNode));
return;
}
this.activeItem = itemNode;
this.openActiveItem(event);
},
/**
* Handle a mousemove event over the list box:
* If the hovered item isn't the selected one, clear the selection.
* @param {Event} event - Triggering event.
*/
onListMouseMove(event) {
let itemNode = this.findParentItemNode(event.target);
if (itemNode != this.selectedItem)
this.selectedItem = null;
},
/**
* Handle a keydown event on the list box.
* @param {Event} event - Triggering event.
*/
onListKeyDown(event) {
if (event.keyCode == KeyEvent.DOM_VK_DOWN) {
// TODO: Refactor this so we pass a direction to a generic method.
// See autocomplete.xml's getNextIndex
event.preventDefault();
if (!this.numItems) {
return;
}
let index = this.selectedIndex + 1;
if (index >= this.numItems) {
index = 0;
}
this.selectedIndex = index;
this.selectedItem.focus();
} else if (event.keyCode == KeyEvent.DOM_VK_UP) {
event.preventDefault();
if (!this.numItems) {
return;
}
let index = this.selectedIndex - 1;
if (index < 0) {
index = this.numItems - 1;
}
this.selectedIndex = index;
this.selectedItem.focus();
} else if (event.keyCode == KeyEvent.DOM_VK_RETURN) {
let selectedItem = this.selectedItem;
if (selectedItem) {
this.activeItem = selectedItem;
this.openActiveItem(event);
}
}
},
/**
* Handle a message, typically sent from browser-readinglist.js
* @param {Event} event - Triggering event.
*/
onMessage(event) {
let msg = event.data;
if (msg.topic != "UpdateActiveItem") {
return;
}
if (!msg.url) {
this.activeItem = null;
} else {
ReadingList.itemForURL(msg.url).then(item => {
this.activeItem = this.itemNodesById.get(item.id);
});
}
}
};
addEventListener("DOMContentLoaded", () => RLSidebar.init());