/* 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", }); }