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
/* import-globals-from controller.js */
/* import-globals-from treeView.js */
// This is loaded into all XUL windows. Wrap in a block to prevent
// leaking to window scope.
{
/**
* Custom element definition for the places tree.
*/
class MozPlacesTree extends customElements.get("tree") {
constructor() {
super();
this.addEventListener("focus", () => {
this._cachedInsertionPoint = undefined;
// See select handler. We need the sidebar's places commandset to be
// updated as well
document.commandDispatcher.updateCommands("focus");
});
this.addEventListener("select", () => {
this._cachedInsertionPoint = undefined;
// This additional complexity is here for the sidebars
var win = window;
while (true) {
win.document.commandDispatcher.updateCommands("focus");
if (win == window.top) {
break;
}
win = win.parent;
}
});
this.addEventListener("dragstart", event => {
if (event.target.localName != "treechildren") {
return;
}
if (this.disableUserActions) {
event.preventDefault();
event.stopPropagation();
return;
}
let nodes = this.selectedNodes;
for (let i = 0; i < nodes.length; i++) {
let node = nodes[i];
// Disallow dragging the root node of a tree.
if (!node.parent) {
event.preventDefault();
event.stopPropagation();
return;
}
// If this node is child of a readonly container or cannot be moved,
// we must force a copy.
if (!this.controller.canMoveNode(node)) {
event.dataTransfer.effectAllowed = "copyLink";
break;
}
}
// Indicate to drag and drop listeners
// whether or not this was the start of the drag
this._isDragSource = true;
this._controller.setDataTransfer(event);
event.stopPropagation();
});
this.addEventListener("dragover", event => {
if (event.target.localName != "treechildren") {
return;
}
let cell = this.getCellAt(event.clientX, event.clientY);
let node =
cell.row != -1
? this.view.nodeForTreeIndex(cell.row)
: this.result.root;
// cache the dropTarget for the view
PlacesControllerDragHelper.currentDropTarget = node;
// We have to calculate the orientation since view.canDrop will use
// it and we want to be consistent with the dropfeedback.
let rowHeight = this.rowHeight;
let eventY =
event.clientY -
this.treeBody.getBoundingClientRect().y -
rowHeight * (cell.row - this.getFirstVisibleRow());
let orientation = Ci.nsITreeView.DROP_BEFORE;
if (cell.row == -1) {
// If the row is not valid we try to insert inside the resultNode.
orientation = Ci.nsITreeView.DROP_ON;
} else if (
PlacesUtils.nodeIsContainer(node) &&
eventY > rowHeight * 0.75
) {
// If we are below the 75% of a container the treeview we try
// to drop after the node.
orientation = Ci.nsITreeView.DROP_AFTER;
} else if (
PlacesUtils.nodeIsContainer(node) &&
eventY > rowHeight * 0.25
) {
// If we are below the 25% of a container the treeview we try
// to drop inside the node.
orientation = Ci.nsITreeView.DROP_ON;
}
if (!this.view.canDrop(cell.row, orientation, event.dataTransfer)) {
return;
}
event.preventDefault();
event.stopPropagation();
});
this.addEventListener("dragend", () => {
this._isDragSource = false;
PlacesControllerDragHelper.currentDropTarget = null;
});
}
connectedCallback() {
if (this.delayConnectedCallback()) {
return;
}
super.connectedCallback();
this._contextMenuShown = false;
this._active = true;
// Force an initial build.
if (this.place) {
// eslint-disable-next-line no-self-assign
this.place = this.place;
}
window.addEventListener("unload", this.disconnectedCallback);
}
get controller() {
return this._controller;
}
set disableUserActions(val) {
if (val) {
this.setAttribute("disableUserActions", "true");
} else {
this.removeAttribute("disableUserActions");
}
}
get disableUserActions() {
return this.getAttribute("disableUserActions") == "true";
}
/**
* overriding
*
* @param {PlacesTreeView} val
* The parent view
*/
set view(val) {
// We save the view so that we can avoid expensive get calls when
// we need to get the view again.
this._view = val;
Object.getOwnPropertyDescriptor(
// eslint-disable-next-line no-undef
XULTreeElement.prototype,
"view"
).set.call(this, val);
}
get view() {
return this._view;
}
get associatedElement() {
return this;
}
set flatList(val) {
if (this.flatList != val) {
this.setAttribute("flatList", val);
// reload with the last place set
if (this.place) {
// eslint-disable-next-line no-self-assign
this.place = this.place;
}
}
}
get flatList() {
return this.getAttribute("flatList") == "true";
}
get result() {
try {
return this.view.QueryInterface(Ci.nsINavHistoryResultObserver).result;
} catch (e) {
return null;
}
}
set place(val) {
this.setAttribute("place", val);
let query = {},
options = {};
PlacesUtils.history.queryStringToQuery(val, query, options);
this.load(query.value, options.value);
}
get place() {
return this.getAttribute("place");
}
get selectedCount() {
return this.view?.selection?.count || 0;
}
get hasSelection() {
return this.selectedCount >= 1;
}
get selectedNodes() {
let nodes = [];
if (!this.hasSelection) {
return nodes;
}
let selection = this.view.selection;
let rc = selection.getRangeCount();
let resultview = this.view;
for (let i = 0; i < rc; ++i) {
let min = {},
max = {};
selection.getRangeAt(i, min, max);
for (let j = min.value; j <= max.value; ++j) {
nodes.push(resultview.nodeForTreeIndex(j));
}
}
return nodes;
}
get removableSelectionRanges() {
// This property exists in addition to selectedNodes because it
// encodes selection ranges (which only occur in list views) into
// the return value. For each removed range, the index at which items
// will be re-inserted upon the remove transaction being performed is
// the first index of the range, so that the view updates correctly.
//
// For example, if we remove rows 2,3,4 and 7,8 from a list, when we
// undo that operation, if we insert what was at row 3 at row 3 again,
// it will show up _after_ the item that was at row 5. So we need to
// insert all items at row 2, and the tree view will update correctly.
//
// Also, this function collapses the selection to remove redundant
// data, e.g. when deleting this selection:
//
// (-) Some Folder
//
// redundant because it is implied by removing "Some Folder". We
// filter out all such redundancies since some partial amount of
// the folder's children may be selected.
//
let nodes = [];
if (!this.hasSelection) {
return nodes;
}
var selection = this.view.selection;
var rc = selection.getRangeCount();
var resultview = this.view;
// This list is kept independently of the range selected (i.e. OUTSIDE
// the for loop) since the row index of a container is unique for the
// entire view, and we could have some really wacky selection and we
// don't want to blow up.
var containers = {};
for (var i = 0; i < rc; ++i) {
var range = [];
var min = {},
max = {};
selection.getRangeAt(i, min, max);
for (var j = min.value; j <= max.value; ++j) {
if (this.view.isContainer(j)) {
containers[j] = true;
}
if (!(this.view.getParentIndex(j) in containers)) {
range.push(resultview.nodeForTreeIndex(j));
}
}
nodes.push(range);
}
return nodes;
}
get draggableSelection() {
return this.selectedNodes;
}
get selectedNode() {
if (this.selectedCount != 1) {
return null;
}
var selection = this.view.selection;
var min = {},
max = {};
selection.getRangeAt(0, min, max);
return this.view.nodeForTreeIndex(min.value);
}
get singleClickOpens() {
return this.getAttribute("singleclickopens") == "true";
}
get insertionPoint() {
// invalidated on selection and focus changes
if (this._cachedInsertionPoint !== undefined) {
return this._cachedInsertionPoint;
}
// there is no insertion point for history queries
// so bail out now and save a lot of work when updating commands
var resultNode = this.result.root;
if (
PlacesUtils.nodeIsQuery(resultNode) &&
PlacesUtils.asQuery(resultNode).queryOptions.queryType ==
Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY
) {
return (this._cachedInsertionPoint = null);
}
var orientation = Ci.nsITreeView.DROP_BEFORE;
// If there is no selection, insert at the end of the container.
if (!this.hasSelection) {
var index = this.view.rowCount - 1;
this._cachedInsertionPoint = this._getInsertionPoint(
index,
orientation
);
return this._cachedInsertionPoint;
}
// This is a two-part process. The first part is determining the drop
// orientation.
// * The default orientation is to drop _before_ the selected item.
// * If the selected item is a container, the default orientation
// is to drop _into_ that container.
//
// Warning: It may be tempting to use tree indexes in this code, but
// you must not, since the tree is nested and as your tree
// index may change when folders before you are opened and
// closed. You must convert your tree index to a node, and
// then use getChildIndex to find your absolute index in
// the parent container instead.
//
var resultView = this.view;
var selection = resultView.selection;
var rc = selection.getRangeCount();
var min = {},
max = {};
selection.getRangeAt(rc - 1, min, max);
// If the sole selection is a container, and we are not in
// a flatlist, insert into it.
// Note that this only applies to _single_ selections,
// if the last element within a multi-selection is a
// container, insert _adjacent_ to the selection.
//
// If the sole selection is the bookmarks toolbar folder, we insert
// into it even if it is not opened
if (
selection.count == 1 &&
resultView.isContainer(max.value) &&
!this.flatList
) {
orientation = Ci.nsITreeView.DROP_ON;
}
this._cachedInsertionPoint = this._getInsertionPoint(
max.value,
orientation
);
return this._cachedInsertionPoint;
}
get isDragSource() {
return this._isDragSource;
}
get ownerWindow() {
return window;
}
set active(val) {
this._active = val;
}
get active() {
return this._active;
}
applyFilter(filterString, folderRestrict, includeHidden) {
// preserve grouping
var queryNode = PlacesUtils.asQuery(this.result.root);
var options = queryNode.queryOptions.clone();
// Make sure we're getting uri results.
// We do not yet support searching into grouped queries or into
// tag containers, so we must fall to the default case.
if (
PlacesUtils.nodeIsHistoryContainer(queryNode) ||
PlacesUtils.nodeIsTagQuery(queryNode) ||
options.resultType == options.RESULTS_AS_TAGS_ROOT ||
options.resultType == options.RESULTS_AS_ROOTS_QUERY
) {
options.resultType = options.RESULTS_AS_URI;
}
var query = PlacesUtils.history.getNewQuery();
query.searchTerms = filterString;
if (folderRestrict) {
query.setParents(folderRestrict);
options.queryType = options.QUERY_TYPE_BOOKMARKS;
Glean.sidebar.search.bookmarks.add(1);
}
options.includeHidden = !!includeHidden;
this.load(query, options);
}
load(query, options) {
let result = PlacesUtils.history.executeQuery(query, options);
if (!this._controller) {
this._controller = new PlacesController(this);
this._controller.disableUserActions = this.disableUserActions;
this.controllers.appendController(this._controller);
}
let treeView = new PlacesTreeView(this);
// Observer removal is done within the view itself. When the tree
// goes away, view.setTree(null) is called, which then
// calls removeObserver.
result.addObserver(treeView);
this.view = treeView;
if (
this.getAttribute("selectfirstnode") == "true" &&
treeView.rowCount > 0
) {
treeView.selection.select(0);
}
this._cachedInsertionPoint = undefined;
}
/**
* Causes a particular node represented by the specified placeURI to be
* selected in the tree. All containers above the node in the hierarchy
* will be opened, so that the node is visible.
*
* @param {string} placeURI
* The URI that should be selected
*/
selectPlaceURI(placeURI) {
// Do nothing if a node matching the given uri is already selected
if (this.hasSelection && this.selectedNode.uri == placeURI) {
return;
}
function findNode(container, nodesURIChecked) {
var containerURI = container.uri;
if (containerURI == placeURI) {
return container;
}
if (nodesURIChecked.includes(containerURI)) {
return null;
}
// never check the contents of the same query
nodesURIChecked.push(containerURI);
var wasOpen = container.containerOpen;
if (!wasOpen) {
container.containerOpen = true;
}
for (let i = 0, count = container.childCount; i < count; ++i) {
var child = container.getChild(i);
var childURI = child.uri;
if (childURI == placeURI) {
return child;
} else if (PlacesUtils.nodeIsContainer(child)) {
var nested = findNode(
PlacesUtils.asContainer(child),
nodesURIChecked
);
if (nested) {
return nested;
}
}
}
if (!wasOpen) {
container.containerOpen = false;
}
return null;
}
var container = this.result.root;
console.assert(container, "No result, cannot select place URI!");
if (!container) {
return;
}
var child = findNode(container, []);
if (child) {
this.selectNode(child);
} else {
// If the specified child could not be located, clear the selection
var selection = this.view.selection;
selection.clearSelection();
}
}
/**
* Causes a particular node to be selected in the tree, resulting in all
* containers above the node in the hierarchy to be opened, so that the
* node is visible.
*
* @param {object} node
* The node that should be selected
*/
selectNode(node) {
var view = this.view;
var parent = node.parent;
if (parent && !parent.containerOpen) {
// Build a list of all of the nodes that are the parent of this one
// in the result.
var parents = [];
var root = this.result.root;
while (parent && parent != root) {
parents.push(parent);
parent = parent.parent;
}
// Walk the list backwards (opening from the root of the hierarchy)
// opening each folder as we go.
for (var i = parents.length - 1; i >= 0; --i) {
let index = view.treeIndexForNode(parents[i]);
if (
index != -1 &&
view.isContainer(index) &&
!view.isContainerOpen(index)
) {
view.toggleOpenState(index);
}
}
// Select the specified node...
}
let index = view.treeIndexForNode(node);
if (index == -1) {
return;
}
view.selection.select(index);
// ... and ensure it's visible, not scrolled off somewhere.
this.ensureRowIsVisible(index);
}
toggleCutNode(aNode, aValue) {
this.view.toggleCutNode(aNode, aValue);
}
_getInsertionPoint(index, orientation) {
var result = this.result;
var resultview = this.view;
var container = result.root;
var dropNearNode = null;
console.assert(container, "null container");
// When there's no selection, assume the container is the container
// the view is populated from (i.e. the result's itemId).
if (index != -1) {
var lastSelected = resultview.nodeForTreeIndex(index);
if (
resultview.isContainer(index) &&
orientation == Ci.nsITreeView.DROP_ON
) {
// If the last selected item is an open container, append _into_
// it, rather than insert adjacent to it.
container = lastSelected;
index = -1;
} else if (
lastSelected.containerOpen &&
orientation == Ci.nsITreeView.DROP_AFTER &&
lastSelected.hasChildren
) {
// If the last selected item is an open container and the user is
// trying to drag into it as a first item, really insert into it.
container = lastSelected;
orientation = Ci.nsITreeView.DROP_ON;
index = 0;
} else {
// Use the last-selected node's container.
container = lastSelected.parent;
// See comment in the treeView.js's copy of this method
if (!container || !container.containerOpen) {
return null;
}
// Avoid the potentially expensive call to getChildIndex
// if we know this container doesn't allow insertion
if (this.controller.disallowInsertion(container)) {
return null;
}
var queryOptions = PlacesUtils.asQuery(result.root).queryOptions;
if (
queryOptions.sortingMode !=
Ci.nsINavHistoryQueryOptions.SORT_BY_NONE
) {
// If we are within a sorted view, insert at the end
index = -1;
} else if (queryOptions.excludeItems || queryOptions.excludeQueries) {
// Some item may be invisible, insert near last selected one.
// We don't replace index here to avoid requests to the db,
// instead it will be calculated later by the controller.
index = -1;
dropNearNode = lastSelected;
} else {
var lsi = container.getChildIndex(lastSelected);
index = orientation == Ci.nsITreeView.DROP_BEFORE ? lsi : lsi + 1;
}
}
}
if (this.controller.disallowInsertion(container)) {
return null;
}
let tagName = PlacesUtils.nodeIsTagQuery(container)
? PlacesUtils.asQuery(container).query.tags[0]
: null;
return new PlacesInsertionPoint({
parentGuid: PlacesUtils.getConcreteItemGuid(container),
index,
orientation,
tagName,
dropNearNode,
});
}
selectAll() {
this.view.selection.selectAll();
}
/**
* This method will select the first node in the tree that matches
* each given item guid. It will open any folder nodes that it needs
* to in order to show the selected items.
*
* @param {Array} aGuids
* Guids to select.
* @param {boolean} aOpenContainers
* Whether or not to open containers.
*/
selectItems(aGuids, aOpenContainers) {
// Never open containers in flat lists.
if (this.flatList) {
aOpenContainers = false;
}
// By default, we do search and select within containers which were
// closed (note that containers in which nodes were not found are
// closed).
if (aOpenContainers === undefined) {
aOpenContainers = true;
}
var guids = aGuids; // don't manipulate the caller's array
// Array of nodes found by findNodes which are to be selected
var nodes = [];
// Array of nodes found by findNodes which should be opened
var nodesToOpen = [];
// A set of GUIDs of container-nodes that were previously searched,
// and thus shouldn't be searched again. This is empty at the initial
// start of the recursion and gets filled in as the recursion
// progresses.
var checkedGuidsSet = new Set();
/**
* Recursively search through a node's children for items
* with the given GUIDs. When a matching item is found, remove its GUID
* from the GUIDs array, and add the found node to the nodes dictionary.
*
* NOTE: This method will leave open any node that had matching items
* in its subtree.
*
* @param {object} node
* The node to search.
* @returns {boolean}
* Returns true if at least one item was found.
*/
function findNodes(node) {
var foundOne = false;
// See if node matches an ID we wanted; add to results.
// For simple folder queries, check both itemId and the concrete
// item id.
var index = guids.indexOf(node.bookmarkGuid);
if (index == -1) {
let concreteGuid = PlacesUtils.getConcreteItemGuid(node);
if (concreteGuid != node.bookmarkGuid) {
index = guids.indexOf(concreteGuid);
}
}
if (index != -1) {
nodes.push(node);
foundOne = true;
guids.splice(index, 1);
}
var concreteGuid = PlacesUtils.getConcreteItemGuid(node);
if (
!guids.length ||
!PlacesUtils.nodeIsContainer(node) ||
checkedGuidsSet.has(concreteGuid)
) {
return foundOne;
}
// Only follow a query if it has been been explicitly opened by the
// caller. We support the "AllBookmarks" case to allow callers to
// specify just the top-level bookmark folders.
let shouldOpen =
aOpenContainers &&
(PlacesUtils.nodeIsFolderOrShortcut(node) ||
(PlacesUtils.nodeIsQuery(node) &&
node.bookmarkGuid == PlacesUIUtils.virtualAllBookmarksGuid));
PlacesUtils.asContainer(node);
if (!node.containerOpen && !shouldOpen) {
return foundOne;
}
checkedGuidsSet.add(concreteGuid);
// Remember the beginning state so that we can re-close
// this node if we don't find any additional results here.
let previousOpenness = node.containerOpen;
node.containerOpen = true;
for (
let i = 0, count = node.childCount;
i < count && guids.length;
++i
) {
let childNode = node.getChild(i);
let found = findNodes(childNode);
if (!foundOne) {
foundOne = found;
}
}
// If we didn't find any additional matches in this node's
// subtree, revert the node to its previous openness.
if (foundOne) {
nodesToOpen.unshift(node);
}
node.containerOpen = previousOpenness;
return foundOne;
}
// Disable notifications while looking for nodes.
let result = this.result;
let didSuppressNotifications = result.suppressNotifications;
if (!didSuppressNotifications) {
result.suppressNotifications = true;
}
try {
findNodes(this.result.root);
} finally {
if (!didSuppressNotifications) {
result.suppressNotifications = false;
}
}
// For all the nodes we've found, highlight the corresponding
// index in the tree.
var resultview = this.view;
var selection = this.view.selection;
selection.selectEventsSuppressed = true;
selection.clearSelection();
// Open nodes containing found items
for (let i = 0; i < nodesToOpen.length; i++) {
nodesToOpen[i].containerOpen = true;
}
let firstValidTreeIndex = -1;
for (let i = 0; i < nodes.length; i++) {
var index = resultview.treeIndexForNode(nodes[i]);
if (index == -1) {
continue;
}
if (firstValidTreeIndex < 0 && index >= 0) {
firstValidTreeIndex = index;
}
selection.rangedSelect(index, index, true);
}
selection.selectEventsSuppressed = false;
// Bring the first valid node into view if necessary
if (firstValidTreeIndex >= 0) {
this.ensureRowIsVisible(firstValidTreeIndex);
}
}
buildContextMenu(aPopup) {
this._contextMenuShown = true;
return this.controller.buildContextMenu(aPopup);
}
destroyContextMenu() {}
disconnectedCallback() {
window.removeEventListener("unload", this.disconnectedCallback);
// Unregister the controller before unlinking the view, otherwise it
// may still try to update commands on a view with a null result.
if (this._controller) {
this._controller.terminate();
this.controllers.removeController(this._controller);
}
if (this.view) {
this.view.uninit();
this.view = null;
}
}
}
customElements.define("places-tree", MozPlacesTree, {
extends: "tree",
});
}