diff options
Diffstat (limited to 'browser/components/places/content/places-tree.js')
-rw-r--r-- | browser/components/places/content/places-tree.js | 864 |
1 files changed, 864 insertions, 0 deletions
diff --git a/browser/components/places/content/places-tree.js b/browser/components/places/content/places-tree.js new file mode 100644 index 0000000000..28edd775f7 --- /dev/null +++ b/browser/components/places/content/places-tree.js @@ -0,0 +1,864 @@ +/* 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/. */ + +/* 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", event => { + 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", event => { + 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", event => { + 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; + } + } + + 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: + // + // http://www.foo.com/ + // (-) Some Folder + // http://www.bar.com/ + // + // ... returning http://www.bar.com/ as part of the selection is + // 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; + Services.telemetry.keyedScalarAdd("sidebar.search", "bookmarks", 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 (var i = 0; i < container.childCount; ++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.nodeIsFolder(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. + var previousOpenness = node.containerOpen; + node.containerOpen = true; + for (var child = 0; child < node.childCount && guids.length; child++) { + var childNode = node.getChild(child); + var 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(aPopup) {} + 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(); + } + // view.setTree(null) will be called upon unsetting the view, which + // breaks the reference cycle between the PlacesTreeView and result. + // See the "setTree" method of PlacesTreeView in treeView.js. + this.view = null; + } + } + + customElements.define("places-tree", MozPlacesTree, { + extends: "tree", + }); +} |