/* 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/. */
/* eslint-env mozilla/browser-window */
/**
* The base view implements everything that's common to all the views.
* It should not be instanced directly, use a derived class instead.
*/
class PlacesViewBase {
/**
* @param {string} placesUrl
* The query string associated with the view.
* @param {DOMElement} rootElt
* The root element for the view.
* @param {DOMElement} viewElt
* The view element.
*/
constructor(placesUrl, rootElt, viewElt) {
this._rootElt = rootElt;
this._viewElt = viewElt;
// Do initialization in subclass now that `this` exists.
this._init?.();
this._controller = new PlacesController(this);
this.place = placesUrl;
this._viewElt.controllers.appendController(this._controller);
}
// The xul element that holds the entire view.
_viewElt = null;
get associatedElement() {
return this._viewElt;
}
get controllers() {
return this._viewElt.controllers;
}
// The xul element that represents the root container.
_rootElt = null;
// Set to true for views that are represented by native widgets (i.e.
// the native mac menu).
_nativeView = false;
static interfaces = [
Ci.nsINavHistoryResultObserver,
Ci.nsISupportsWeakReference,
];
QueryInterface = ChromeUtils.generateQI(PlacesViewBase.interfaces);
_place = "";
get place() {
return this._place;
}
set place(val) {
this._place = val;
let history = PlacesUtils.history;
let query = {},
options = {};
history.queryStringToQuery(val, query, options);
let result = history.executeQuery(query.value, options.value);
result.addObserver(this);
}
_result = null;
get result() {
return this._result;
}
set result(val) {
if (this._result == val) {
return;
}
if (this._result) {
this._result.removeObserver(this);
this._resultNode.containerOpen = false;
}
if (this._rootElt.localName == "menupopup") {
this._rootElt._built = false;
}
this._result = val;
if (val) {
this._resultNode = val.root;
this._rootElt._placesNode = this._resultNode;
this._domNodes = new Map();
this._domNodes.set(this._resultNode, this._rootElt);
// This calls _rebuild through invalidateContainer.
this._resultNode.containerOpen = true;
} else {
this._resultNode = null;
delete this._domNodes;
}
}
/**
* Gets the DOM node used for the given places node.
*
* @param {object} aPlacesNode
* a places result node.
* @param {boolean} aAllowMissing
* whether the node may be missing
* @returns {object|null} The associated DOM node.
* @throws if there is no DOM node set for aPlacesNode.
*/
_getDOMNodeForPlacesNode(aPlacesNode, aAllowMissing = false) {
let node = this._domNodes.get(aPlacesNode, null);
if (!node && !aAllowMissing) {
throw new Error(
"No DOM node set for aPlacesNode.\nnode.type: " +
aPlacesNode.type +
". node.parent: " +
aPlacesNode
);
}
return node;
}
get controller() {
return this._controller;
}
get selType() {
return "single";
}
selectItems() {}
selectAll() {}
get selectedNode() {
if (this._contextMenuShown) {
let anchor = this._contextMenuShown.triggerNode;
if (!anchor) {
return null;
}
if (anchor._placesNode) {
return this._rootElt == anchor ? null : anchor._placesNode;
}
anchor = anchor.parentNode;
return this._rootElt == anchor ? null : anchor._placesNode || null;
}
return null;
}
get hasSelection() {
return this.selectedNode != null;
}
get selectedNodes() {
let selectedNode = this.selectedNode;
return selectedNode ? [selectedNode] : [];
}
get singleClickOpens() {
return true;
}
get removableSelectionRanges() {
// On static content the current selectedNode would be the selection's
// parent node. We don't want to allow removing a node when the
// selection is not explicit.
let popupNode = PlacesUIUtils.lastContextMenuTriggerNode;
if (popupNode && (popupNode == "menupopup" || !popupNode._placesNode)) {
return [];
}
return [this.selectedNodes];
}
get draggableSelection() {
return [this._draggedElt];
}
get insertionPoint() {
// There is no insertion point for history queries, so bail out now and
// save a lot of work when updating commands.
let resultNode = this._resultNode;
if (
PlacesUtils.nodeIsQuery(resultNode) &&
PlacesUtils.asQuery(resultNode).queryOptions.queryType ==
Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY
) {
return null;
}
// By default, the insertion point is at the top level, at the end.
let index = PlacesUtils.bookmarks.DEFAULT_INDEX;
let container = this._resultNode;
let orientation = Ci.nsITreeView.DROP_BEFORE;
let tagName = null;
let selectedNode = this.selectedNode;
if (selectedNode) {
let popupNode = PlacesUIUtils.lastContextMenuTriggerNode;
if (
!popupNode._placesNode ||
popupNode._placesNode == this._resultNode ||
popupNode._placesNode.itemId == -1 ||
!selectedNode.parent
) {
// If a static menuitem is selected, or if the root node is selected,
// the insertion point is inside the folder, at the end.
container = selectedNode;
orientation = Ci.nsITreeView.DROP_ON;
} else {
// In all other cases the insertion point is before that node.
container = selectedNode.parent;
index = container.getChildIndex(selectedNode);
if (PlacesUtils.nodeIsTagQuery(container)) {
tagName = PlacesUtils.asQuery(container).query.tags[0];
}
}
}
if (this.controller.disallowInsertion(container)) {
return null;
}
return new PlacesInsertionPoint({
parentGuid: PlacesUtils.getConcreteItemGuid(container),
index,
orientation,
tagName,
});
}
buildContextMenu(aPopup) {
this._contextMenuShown = aPopup;
window.updateCommands("places");
// Ensure that an existing "Show Other Bookmarks" item is removed before adding it
// again.
let existingOtherBookmarksItem = aPopup.querySelector(
"#show-other-bookmarks_PersonalToolbar"
);
existingOtherBookmarksItem?.remove();
let manageBookmarksMenu = aPopup.querySelector(
"#placesContext_showAllBookmarks"
);
// Add the View menu for the Bookmarks Toolbar and "Show Other Bookmarks" menu item
// if the click originated from the Bookmarks Toolbar.
let existingSubmenu = aPopup.querySelector("#toggle_PersonalToolbar");
existingSubmenu?.remove();
let bookmarksToolbar = document.getElementById("PersonalToolbar");
if (bookmarksToolbar?.contains(aPopup.triggerNode)) {
manageBookmarksMenu.removeAttribute("hidden");
let menu = BookmarkingUI.buildBookmarksToolbarSubmenu(bookmarksToolbar);
aPopup.insertBefore(menu, manageBookmarksMenu);
if (
aPopup.triggerNode.id === "OtherBookmarks" ||
aPopup.triggerNode.id === "PlacesChevron" ||
aPopup.triggerNode.id === "PlacesToolbarItems" ||
aPopup.triggerNode.parentNode.id === "PlacesToolbarItems"
) {
let otherBookmarksMenuItem =
BookmarkingUI.buildShowOtherBookmarksMenuItem();
if (otherBookmarksMenuItem) {
aPopup.insertBefore(otherBookmarksMenuItem, menu.nextElementSibling);
}
}
} else {
manageBookmarksMenu.setAttribute("hidden", "true");
}
return this.controller.buildContextMenu(aPopup);
}
destroyContextMenu(aPopup) {
this._contextMenuShown = null;
}
clearAllContents(aPopup) {
let kid = aPopup.firstElementChild;
while (kid) {
let next = kid.nextElementSibling;
if (!kid.classList.contains("panel-header")) {
kid.remove();
}
kid = next;
}
aPopup._emptyMenuitem = aPopup._startMarker = aPopup._endMarker = null;
}
_cleanPopup(aPopup, aDelay) {
// Ensure markers are here when `invalidateContainer` is called before the
// popup is shown, which may the case for panelviews, for example.
this._ensureMarkers(aPopup);
// Remove Places nodes from the popup.
let child = aPopup._startMarker;
while (child.nextElementSibling != aPopup._endMarker) {
let sibling = child.nextElementSibling;
if (sibling._placesNode && !aDelay) {
aPopup.removeChild(sibling);
} else if (sibling._placesNode && aDelay) {
// HACK (bug 733419): the popups originating from the OS X native
// menubar don't live-update while open, thus we don't clean it
// until the next popupshowing, to avoid zombie menuitems.
if (!aPopup._delayedRemovals) {
aPopup._delayedRemovals = [];
}
aPopup._delayedRemovals.push(sibling);
child = child.nextElementSibling;
} else {
child = child.nextElementSibling;
}
}
}
_rebuildPopup(aPopup) {
let resultNode = aPopup._placesNode;
if (!resultNode.containerOpen) {
return;
}
this._cleanPopup(aPopup);
let cc = resultNode.childCount;
if (cc > 0) {
this._setEmptyPopupStatus(aPopup, false);
let fragment = document.createDocumentFragment();
for (let i = 0; i < cc; ++i) {
let child = resultNode.getChild(i);
this._insertNewItemToPopup(child, fragment);
}
aPopup.insertBefore(fragment, aPopup._endMarker);
} else {
this._setEmptyPopupStatus(aPopup, true);
}
aPopup._built = true;
}
_removeChild(aChild) {
aChild.remove();
}
_setEmptyPopupStatus(aPopup, aEmpty) {
if (!aPopup._emptyMenuitem) {
aPopup._emptyMenuitem = document.createXULElement("menuitem");
aPopup._emptyMenuitem.setAttribute("disabled", true);
aPopup._emptyMenuitem.className = "bookmark-item";
document.l10n.setAttributes(
aPopup._emptyMenuitem,
"places-empty-bookmarks-folder"
);
}
if (aEmpty) {
aPopup.setAttribute("emptyplacesresult", "true");
// Don't add the menuitem if there is static content.
if (
!aPopup._startMarker.previousElementSibling &&
!aPopup._endMarker.nextElementSibling
) {
aPopup.insertBefore(aPopup._emptyMenuitem, aPopup._endMarker);
}
} else {
aPopup.removeAttribute("emptyplacesresult");
try {
aPopup.removeChild(aPopup._emptyMenuitem);
} catch (ex) {}
}
}
_createDOMNodeForPlacesNode(aPlacesNode) {
this._domNodes.delete(aPlacesNode);
let element;
let type = aPlacesNode.type;
if (type == Ci.nsINavHistoryResultNode.RESULT_TYPE_SEPARATOR) {
element = document.createXULElement("menuseparator");
} else {
if (type == Ci.nsINavHistoryResultNode.RESULT_TYPE_URI) {
element = document.createXULElement("menuitem");
element.className =
"menuitem-iconic bookmark-item menuitem-with-favicon";
element.setAttribute(
"scheme",
PlacesUIUtils.guessUrlSchemeForUI(aPlacesNode.uri)
);
} else if (PlacesUtils.containerTypes.includes(type)) {
element = document.createXULElement("menu");
element.setAttribute("container", "true");
if (aPlacesNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_QUERY) {
element.setAttribute("query", "true");
if (PlacesUtils.nodeIsTagQuery(aPlacesNode)) {
element.setAttribute("tagContainer", "true");
} else if (PlacesUtils.nodeIsDay(aPlacesNode)) {
element.setAttribute("dayContainer", "true");
} else if (PlacesUtils.nodeIsHost(aPlacesNode)) {
element.setAttribute("hostContainer", "true");
}
}
let popup = document.createXULElement("menupopup", {
is: "places-popup",
});
popup._placesNode = PlacesUtils.asContainer(aPlacesNode);
if (!this._nativeView) {
popup.setAttribute("placespopup", "true");
}
element.appendChild(popup);
element.className = "menu-iconic bookmark-item";
this._domNodes.set(aPlacesNode, popup);
} else {
throw new Error("Unexpected node");
}
element.setAttribute("label", PlacesUIUtils.getBestTitle(aPlacesNode));
let icon = aPlacesNode.icon;
if (icon) {
element.setAttribute("image", icon);
}
}
element._placesNode = aPlacesNode;
if (!this._domNodes.has(aPlacesNode)) {
this._domNodes.set(aPlacesNode, element);
}
return element;
}
_insertNewItemToPopup(aNewChild, aInsertionNode, aBefore = null) {
let element = this._createDOMNodeForPlacesNode(aNewChild);
aInsertionNode.insertBefore(element, aBefore);
return element;
}
toggleCutNode(aPlacesNode, aValue) {
let elt = this._getDOMNodeForPlacesNode(aPlacesNode);
// We may get the popup for menus, but we need the menu itself.
if (elt.localName == "menupopup") {
elt = elt.parentNode;
}
if (aValue) {
elt.setAttribute("cutting", "true");
} else {
elt.removeAttribute("cutting");
}
}
nodeURIChanged(aPlacesNode, aURIString) {
let elt = this._getDOMNodeForPlacesNode(aPlacesNode, true);
// There's no DOM node, thus there's nothing to be done when the URI changes.
if (!elt) {
return;
}
// Here we need the