diff options
Diffstat (limited to 'browser/components/places/content')
19 files changed, 12057 insertions, 0 deletions
diff --git a/browser/components/places/content/bookmarkProperties.js b/browser/components/places/content/bookmarkProperties.js new file mode 100644 index 0000000000..bc98820a7b --- /dev/null +++ b/browser/components/places/content/bookmarkProperties.js @@ -0,0 +1,519 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* 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/. */ + +/** + * The panel is initialized based on data given in the js object passed + * as window.arguments[0]. The object must have the following fields set: + * @ action (String). Possible values: + * - "add" - for adding a new item. + * @ type (String). Possible values: + * - "bookmark" + * - "folder" + * @ URIList (Array of nsIURI objects) - optional, list of uris to + * be bookmarked under the new folder. + * @ uri (nsIURI object) - optional, the default uri for the new item. + * The property is not used for the "folder with items" type. + * @ title (String) - optional, the default title for the new item. + * @ defaultInsertionPoint (InsertionPoint JS object) - optional, the + * default insertion point for the new item. + * @ keyword (String) - optional, the default keyword for the new item. + * @ postData (String) - optional, POST data to accompany the keyword. + * @ charSet (String) - optional, character-set to accompany the keyword. + * Notes: + * 1) If |uri| is set for a bookmark and |title| isn't, + * the dialog will query the history tables for the title associated + * with the given uri. If the dialog is set to adding a folder with + * bookmark items under it (see URIList), a default static title is + * used ("[Folder Name]"). + * 2) The index field of the default insertion point is ignored if + * the folder picker is shown. + * - "edit" - for editing a bookmark item or a folder. + * @ type (String). Possible values: + * - "bookmark" + * @ node (an nsINavHistoryResultNode object) - a node representing + * the bookmark. + * - "folder" + * @ node (an nsINavHistoryResultNode object) - a node representing + * the folder. + * @ hiddenRows (Strings array) - optional, list of rows to be hidden + * regardless of the item edited or added by the dialog. + * Possible values: + * - "title" + * - "location" + * - "keyword" + * - "tags" + * - "folderPicker" - hides both the tree and the menu. + * + * window.arguments[0].bookmarkGuid is set to the guid of the item, if the + * dialog is accepted. + */ + +/* import-globals-from editBookmark.js */ + +/* Shared Places Import - change other consumers if you change this: */ +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +ChromeUtils.defineESModuleGetters(this, { + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", +}); +XPCOMUtils.defineLazyScriptGetter( + this, + "PlacesTreeView", + "chrome://browser/content/places/treeView.js" +); +XPCOMUtils.defineLazyScriptGetter( + this, + ["PlacesInsertionPoint", "PlacesController", "PlacesControllerDragHelper"], + "chrome://browser/content/places/controller.js" +); +/* End Shared Places Import */ + +const BOOKMARK_ITEM = 0; +const BOOKMARK_FOLDER = 1; + +const ACTION_EDIT = 0; +const ACTION_ADD = 1; + +var BookmarkPropertiesPanel = { + /** UI Text Strings */ + __strings: null, + get _strings() { + if (!this.__strings) { + this.__strings = document.getElementById("stringBundle"); + } + return this.__strings; + }, + + _action: null, + _itemType: null, + _uri: null, + _title: "", + _URIs: [], + _keyword: "", + _postData: null, + _charSet: "", + + _defaultInsertionPoint: null, + _hiddenRows: [], + + /** + * @returns {string} + * This method returns the correct label for the dialog's "accept" + * button based on the variant of the dialog. + */ + _getAcceptLabel: function BPP__getAcceptLabel() { + return this._strings.getString("dialogAcceptLabelSaveItem"); + }, + + /** + * @returns {string} + * This method returns the correct title for the current variant + * of this dialog. + */ + _getDialogTitle: function BPP__getDialogTitle() { + if (this._action == ACTION_ADD) { + if (this._itemType == BOOKMARK_ITEM) { + return this._strings.getString("dialogTitleAddNewBookmark2"); + } + + // add folder + if (this._itemType != BOOKMARK_FOLDER) { + throw new Error("Unknown item type"); + } + if (this._URIs.length) { + return this._strings.getString("dialogTitleAddMulti"); + } + + return this._strings.getString("dialogTitleAddBookmarkFolder"); + } + if (this._action == ACTION_EDIT) { + if (this._itemType === BOOKMARK_ITEM) { + return this._strings.getString("dialogTitleEditBookmark2"); + } + + return this._strings.getString("dialogTitleEditBookmarkFolder"); + } + return ""; + }, + + /** + * Determines the initial data for the item edited or added by this dialog + */ + async _determineItemInfo() { + let dialogInfo = window.arguments[0]; + this._action = dialogInfo.action == "add" ? ACTION_ADD : ACTION_EDIT; + this._hiddenRows = dialogInfo.hiddenRows ? dialogInfo.hiddenRows : []; + if (this._action == ACTION_ADD) { + if (!("type" in dialogInfo)) { + throw new Error("missing type property for add action"); + } + + if ("title" in dialogInfo) { + this._title = dialogInfo.title; + } + + if ("defaultInsertionPoint" in dialogInfo) { + this._defaultInsertionPoint = dialogInfo.defaultInsertionPoint; + } else { + let parentGuid = await PlacesUIUtils.defaultParentGuid; + this._defaultInsertionPoint = new PlacesInsertionPoint({ + parentGuid, + }); + } + + switch (dialogInfo.type) { + case "bookmark": + this._itemType = BOOKMARK_ITEM; + if ("uri" in dialogInfo) { + if (!(dialogInfo.uri instanceof Ci.nsIURI)) { + throw new Error("uri property should be a uri object"); + } + this._uri = dialogInfo.uri; + if (typeof this._title != "string") { + this._title = + (await PlacesUtils.history.fetch(this._uri)) || this._uri.spec; + } + } else { + this._uri = Services.io.newURI("about:blank"); + this._title = this._strings.getString("newBookmarkDefault"); + this._dummyItem = true; + } + + if ("keyword" in dialogInfo) { + this._keyword = dialogInfo.keyword; + this._isAddKeywordDialog = true; + if ("postData" in dialogInfo) { + this._postData = dialogInfo.postData; + } + if ("charSet" in dialogInfo) { + this._charSet = dialogInfo.charSet; + } + } + break; + + case "folder": + this._itemType = BOOKMARK_FOLDER; + if (!this._title) { + if ("URIList" in dialogInfo) { + this._title = this._strings.getString("bookmarkAllTabsDefault"); + this._URIs = dialogInfo.URIList; + } else { + this._title = this._strings.getString("newFolderDefault"); + this._dummyItem = true; + } + } + break; + } + } else { + // edit + this._node = dialogInfo.node; + this._title = this._node.title; + if (PlacesUtils.nodeIsFolder(this._node)) { + this._itemType = BOOKMARK_FOLDER; + } else if (PlacesUtils.nodeIsURI(this._node)) { + this._itemType = BOOKMARK_ITEM; + } + } + }, + + /** + * This method should be called by the onload of the Bookmark Properties + * dialog to initialize the state of the panel. + */ + async onDialogLoad() { + document.addEventListener("dialogaccept", function () { + BookmarkPropertiesPanel.onDialogAccept(); + }); + document.addEventListener("dialogcancel", function () { + BookmarkPropertiesPanel.onDialogCancel(); + }); + + // Disable the buttons until we have all the information required. + let acceptButton = document + .getElementById("bookmarkpropertiesdialog") + .getButton("accept"); + acceptButton.disabled = true; + await this._determineItemInfo(); + document.title = this._getDialogTitle(); + + // Set adjustable title + let title = { raw: document.title }; + document.documentElement.setAttribute("headertitle", JSON.stringify(title)); + + let iconUrl = this._getIconUrl(); + if (iconUrl) { + document.documentElement.style.setProperty( + "--icon-url", + `url(${iconUrl})` + ); + } + + await this._initDialog(); + }, + + _getIconUrl() { + let url = "chrome://browser/skin/bookmark-hollow.svg"; + + if (this._action === ACTION_EDIT && this._itemType === BOOKMARK_ITEM) { + url = window.arguments[0]?.node?.icon; + } + + return url; + }, + + /** + * Initializes the dialog, gathering the required bookmark data. This function + * will enable the accept button (if appropraite) when it is complete. + */ + async _initDialog() { + let acceptButton = document + .getElementById("bookmarkpropertiesdialog") + .getButton("accept"); + acceptButton.label = this._getAcceptLabel(); + let acceptButtonDisabled = false; + + // Since elements can be unhidden asynchronously, we must observe their + // mutations and resize the dialog accordingly. + this._mutationObserver = new MutationObserver(mutations => { + for (let { target, oldValue } of mutations) { + let hidden = target.getAttribute("hidden") == "true"; + if ( + target.classList.contains("hideable") && + hidden != (oldValue == "true") + ) { + // To support both kind of dialogs (window and dialog-box) we need + // both resizeBy and sizeToContent, otherwise either the dialog + // doesn't resize, or it gets empty unused space. + if (hidden) { + let diff = this._mutationObserver._heightsById.get(target.id); + window.resizeBy(0, -diff); + } else { + let diff = target.getBoundingClientRect().height; + this._mutationObserver._heightsById.set(target.id, diff); + window.resizeBy(0, diff); + } + window.sizeToContent(); + } + } + }); + this._mutationObserver._heightsById = new Map(); + this._mutationObserver.observe(document, { + subtree: true, + attributeOldValue: true, + attributeFilter: ["hidden"], + }); + + switch (this._action) { + case ACTION_EDIT: + await gEditItemOverlay.initPanel({ + node: this._node, + hiddenRows: this._hiddenRows, + focusedElement: "first", + }); + acceptButtonDisabled = gEditItemOverlay.readOnly; + break; + case ACTION_ADD: + this._node = await this._promiseNewItem(); + // Edit the new item + await gEditItemOverlay.initPanel({ + node: this._node, + hiddenRows: this._hiddenRows, + postData: this._postData, + focusedElement: "first", + }); + + // Empty location field if the uri is about:blank, this way inserting a new + // url will be easier for the user, Accept button will be automatically + // disabled by the input listener until the user fills the field. + let locationField = this._element("locationField"); + if (locationField.value == "about:blank") { + locationField.value = ""; + } + + // if this is an uri related dialog disable accept button until + // the user fills an uri value. + if (this._itemType == BOOKMARK_ITEM) { + acceptButtonDisabled = !this._inputIsValid(); + } + break; + } + + if (!gEditItemOverlay.readOnly) { + // Listen on uri fields to enable accept button if input is valid + if (this._itemType == BOOKMARK_ITEM) { + this._element("locationField").addEventListener("input", this); + if (this._isAddKeywordDialog) { + this._element("keywordField").addEventListener("input", this); + } + } + } + // Only enable the accept button once we've finished everything. + acceptButton.disabled = acceptButtonDisabled; + }, + + // EventListener + handleEvent: function BPP_handleEvent(aEvent) { + var target = aEvent.target; + switch (aEvent.type) { + case "input": + if ( + target.id == "editBMPanel_locationField" || + target.id == "editBMPanel_keywordField" + ) { + // Check uri fields to enable accept button if input is valid + document + .getElementById("bookmarkpropertiesdialog") + .getButton("accept").disabled = !this._inputIsValid(); + } + break; + } + }, + + // nsISupports + QueryInterface: ChromeUtils.generateQI([]), + + _element: function BPP__element(aID) { + return document.getElementById("editBMPanel_" + aID); + }, + + onDialogUnload() { + // gEditItemOverlay does not exist anymore here, so don't rely on it. + this._mutationObserver.disconnect(); + delete this._mutationObserver; + + // Calling removeEventListener with arguments which do not identify any + // currently registered EventListener on the EventTarget has no effect. + this._element("locationField").removeEventListener("input", this); + this._element("keywordField").removeEventListener("input", this); + }, + + onDialogAccept() { + // We must blur current focused element to save its changes correctly + document.commandDispatcher.focusedElement?.blur(); + + // Get the states to compare bookmark and editedBookmark + window.arguments[0].bookmarkState = gEditItemOverlay._bookmarkState; + + // We have to uninit the panel first, otherwise late changes could force it + // to commit more transactions. + gEditItemOverlay.uninitPanel(true); + + window.arguments[0].bookmarkGuid = this._node.bookmarkGuid; + }, + + onDialogCancel() { + // We have to uninit the panel first, otherwise late changes could force it + // to commit more transactions. + gEditItemOverlay.uninitPanel(true); + }, + + /** + * This method checks to see if the input fields are in a valid state. + * + * @returns {boolean} true if the input is valid, false otherwise + */ + _inputIsValid: function BPP__inputIsValid() { + if ( + this._itemType == BOOKMARK_ITEM && + !this._containsValidURI("locationField") + ) { + return false; + } + if ( + this._isAddKeywordDialog && + !this._element("keywordField").value.length + ) { + return false; + } + + return true; + }, + + /** + * Determines whether the input with the given ID contains a + * string that can be converted into an nsIURI. + * + * @param {number} aTextboxID + * the ID of the textbox element whose contents we'll test + * + * @returns {boolean} true if the textbox contains a valid URI string, false otherwise + */ + _containsValidURI: function BPP__containsValidURI(aTextboxID) { + try { + var value = this._element(aTextboxID).value; + if (value) { + Services.uriFixup.getFixupURIInfo(value); + return true; + } + } catch (e) {} + return false; + }, + + /** + * [New Item Mode] Get the insertion point details for the new item, given + * dialog state and opening arguments. + * + * The container-identifier and insertion-index are returned separately in + * the form of [containerIdentifier, insertionIndex] + */ + async _getInsertionPointDetails() { + return [ + await this._defaultInsertionPoint.getIndex(), + this._defaultInsertionPoint.guid, + ]; + }, + + async _promiseNewItem() { + let [index, parentGuid] = await this._getInsertionPointDetails(); + + let info = { parentGuid, index, title: this._title }; + if (this._itemType == BOOKMARK_ITEM) { + info.url = this._uri; + if (this._keyword) { + info.keyword = this._keyword; + } + if (this._postData) { + info.postData = this._postData; + } + + if (this._charSet) { + PlacesUIUtils.setCharsetForPage(this._uri, this._charSet, window).catch( + console.error + ); + } + } else if (this._itemType == BOOKMARK_FOLDER) { + // NewFolder requires a url rather than uri. + info.children = this._URIs.map(item => { + return { url: item.uri, title: item.title }; + }); + } else { + throw new Error(`unexpected value for _itemType: ${this._itemType}`); + } + return Object.freeze({ + index, + bookmarkGuid: PlacesUtils.bookmarks.unsavedGuid, + title: this._title, + uri: this._uri ? this._uri.spec : "", + type: + this._itemType == BOOKMARK_ITEM + ? Ci.nsINavHistoryResultNode.RESULT_TYPE_URI + : Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER, + parent: { + bookmarkGuid: parentGuid, + type: Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER, + }, + children: info.children, + }); + }, +}; + +document.addEventListener("DOMContentLoaded", function () { + // Content initialization is asynchronous, thus set mozSubdialogReady + // immediately to properly wait for it. + document.mozSubdialogReady = BookmarkPropertiesPanel.onDialogLoad() + .catch(ex => console.error(`Failed to initialize dialog: ${ex}`)) + .then(() => window.sizeToContent()); +}); diff --git a/browser/components/places/content/bookmarkProperties.xhtml b/browser/components/places/content/bookmarkProperties.xhtml new file mode 100644 index 0000000000..047652a52e --- /dev/null +++ b/browser/components/places/content/bookmarkProperties.xhtml @@ -0,0 +1,58 @@ +<?xml version="1.0"?> + +<!-- 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/. --> + +<!DOCTYPE window> + +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + id="bookmarkproperties" + headerparent="bookmarkpropertiesdialog" + neediconheader="true" + onunload="BookmarkPropertiesPanel.onDialogUnload();" + style="min-width: 40em;"> +<dialog id="bookmarkpropertiesdialog" + buttons="accept, cancel"> + + <linkset> + <html:link rel="stylesheet" href="chrome://global/skin/global.css" /> + <html:link + rel="stylesheet" + href="chrome://global/content/commonDialog.css" + /> + <html:link rel="stylesheet" href="chrome://global/skin/commonDialog.css" /> + <html:link + rel="stylesheet" + href="chrome://browser/skin/places/editBookmark.css" + /> + <html:link + rel="stylesheet" + href="chrome://browser/skin/places/tree-icons.css" + /> + <html:link + rel="stylesheet" + href="chrome://browser/content/places/places.css" + /> + + <html:link rel="localization" href="browser/editBookmarkOverlay.ftl"/> + </linkset> + + <stringbundleset id="stringbundleset"> + <stringbundle id="stringBundle" + src="chrome://browser/locale/places/bookmarkProperties.properties"/> + </stringbundleset> + + <script src="chrome://browser/content/places/editBookmark.js"/> + <script src="chrome://browser/content/places/bookmarkProperties.js"/> + <script src="chrome://global/content/globalOverlay.js"/> + <script src="chrome://global/content/editMenuOverlay.js"/> + <script src="chrome://browser/content/utilityOverlay.js"/> + <script src="chrome://browser/content/places/places-tree.js"/> + <script src="chrome://global/content/adjustableTitle.js"/> + +#include editBookmarkPanel.inc.xhtml + +</dialog> +</window> diff --git a/browser/components/places/content/bookmarksHistoryTooltip.inc.xhtml b/browser/components/places/content/bookmarksHistoryTooltip.inc.xhtml new file mode 100644 index 0000000000..445f2cdcb4 --- /dev/null +++ b/browser/components/places/content/bookmarksHistoryTooltip.inc.xhtml @@ -0,0 +1,14 @@ +# 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/. + +<!-- Bookmarks and history tooltip --> +<tooltip id="bhTooltip" noautohide="true" + class="places-tooltip" + onpopupshowing="return window.top.BookmarksEventHandler.fillInBHTooltip(this, event)" + onpopuphiding="this.removeAttribute('position')"> + <box class="places-tooltip-box"> + <description class="tooltip-label places-tooltip-title"/> + <description crop="center" class="tooltip-label places-tooltip-uri uri-element"/> + </box> +</tooltip> diff --git a/browser/components/places/content/bookmarksSidebar.js b/browser/components/places/content/bookmarksSidebar.js new file mode 100644 index 0000000000..5da984543b --- /dev/null +++ b/browser/components/places/content/bookmarksSidebar.js @@ -0,0 +1,78 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* 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/. */ + +/* Shared Places Import - change other consumers if you change this: */ +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + PlacesTransactions: "resource://gre/modules/PlacesTransactions.sys.mjs", + PlacesUIUtils: "resource:///modules/PlacesUIUtils.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyScriptGetter( + this, + "PlacesTreeView", + "chrome://browser/content/places/treeView.js" +); +XPCOMUtils.defineLazyScriptGetter( + this, + ["PlacesInsertionPoint", "PlacesController", "PlacesControllerDragHelper"], + "chrome://browser/content/places/controller.js" +); +/* End Shared Places Import */ +var gCumulativeSearches = 0; + +function init() { + let uidensity = window.top.document.documentElement.getAttribute("uidensity"); + if (uidensity) { + document.documentElement.setAttribute("uidensity", uidensity); + } + + document.getElementById("bookmarks-view").place = + "place:type=" + Ci.nsINavHistoryQueryOptions.RESULTS_AS_ROOTS_QUERY; +} + +function searchBookmarks(aSearchString) { + var tree = document.getElementById("bookmarks-view"); + if (!aSearchString) { + // eslint-disable-next-line no-self-assign + tree.place = tree.place; + } else { + Services.telemetry.keyedScalarAdd("sidebar.search", "bookmarks", 1); + gCumulativeSearches++; + tree.applyFilter(aSearchString, PlacesUtils.bookmarks.userContentRoots); + } +} + +function updateTelemetry(urlsOpened = []) { + let searchesHistogram = Services.telemetry.getHistogramById( + "PLACES_BOOKMARKS_SEARCHBAR_CUMULATIVE_SEARCHES" + ); + searchesHistogram.add(gCumulativeSearches); + clearCumulativeCounter(); + + Services.telemetry.keyedScalarAdd( + "sidebar.link", + "bookmarks", + urlsOpened.length + ); +} + +function clearCumulativeCounter() { + gCumulativeSearches = 0; +} + +function unloadBookmarksSidebar() { + clearCumulativeCounter(); + PlacesUIUtils.setMouseoverURL("", window); +} + +window.addEventListener("SidebarFocused", () => + document.getElementById("search-box").focus() +); diff --git a/browser/components/places/content/bookmarksSidebar.xhtml b/browser/components/places/content/bookmarksSidebar.xhtml new file mode 100644 index 0000000000..b706c4208f --- /dev/null +++ b/browser/components/places/content/bookmarksSidebar.xhtml @@ -0,0 +1,76 @@ +<?xml version="1.0"?> <!-- -*- Mode: xml; indent-tabs-mode: nil; -*- --> +<!-- 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/. --> + +<!DOCTYPE window> + +<window id="bookmarksPanel" + class="sidebar-panel" + xmlns:html="http://www.w3.org/1999/xhtml" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + onload="init();" + onunload="unloadBookmarksSidebar();" + data-l10n-id="bookmarks-sidebar-content"> + + <script src="chrome://browser/content/places/bookmarksSidebar.js"/> + <script src="chrome://global/content/globalOverlay.js"/> + <script src="chrome://browser/content/utilityOverlay.js"/> + <script src="chrome://browser/content/contentTheme.js"/> + <script src="chrome://browser/content/places/places-tree.js"/> + <script src="chrome://global/content/editMenuOverlay.js"/> + + <linkset> + <html:link + rel="stylesheet" + href="chrome://browser/content/places/places.css" + /> + <html:link + rel="stylesheet" + href="chrome://browser/content/usercontext/usercontext.css" + /> + <html:link rel="stylesheet" href="chrome://global/skin/global.css" /> + <html:link + rel="stylesheet" + href="chrome://browser/skin/places/tree-icons.css" + /> + <html:link + rel="stylesheet" + href="chrome://browser/skin/places/sidebar.css" + /> + + <html:link rel="localization" href="toolkit/global/textActions.ftl"/> + <html:link rel="localization" href="browser/browser.ftl"/> + <html:link rel="localization" href="browser/places.ftl"/> + </linkset> + +#include placesCommands.inc.xhtml +#include placesContextMenu.inc.xhtml +#include bookmarksHistoryTooltip.inc.xhtml + + <hbox id="sidebar-search-container" align="center"> + <search-textbox id="search-box" flex="1" + data-l10n-id="places-bookmarks-search" + data-l10n-attrs="placeholder" + aria-controls="bookmarks-view" + oncommand="searchBookmarks(this.value);"/> + </hbox> + + <tree id="bookmarks-view" + class="sidebar-placesTree" + is="places-tree" + flex="1" + hidecolumnpicker="true" + context="placesContext" + singleclickopens="true" + onkeypress="PlacesUIUtils.onSidebarTreeKeyPress(event);" + onclick="PlacesUIUtils.onSidebarTreeClick(event);" + onmousemove="PlacesUIUtils.onSidebarTreeMouseMove(event);" + onmouseout="PlacesUIUtils.setMouseoverURL('', window);"> + <treecols> + <treecol id="title" flex="1" primary="true" hideheader="true"/> + </treecols> + <treechildren view="bookmarks-view" + class="sidebar-placesTreechildren" flex="1" tooltip="bhTooltip"/> + </tree> +</window> diff --git a/browser/components/places/content/browserPlacesViews.js b/browser/components/places/content/browserPlacesViews.js new file mode 100644 index 0000000000..35cdd2b9d5 --- /dev/null +++ b/browser/components/places/content/browserPlacesViews.js @@ -0,0 +1,2263 @@ +/* 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 <menu>. + if (elt.localName == "menupopup") { + elt = elt.parentNode; + } + + elt.setAttribute( + "scheme", + PlacesUIUtils.guessUrlSchemeForUI(aPlacesNode.uri) + ); + } + + nodeIconChanged(aPlacesNode) { + let elt = this._getDOMNodeForPlacesNode(aPlacesNode, true); + + // There's no UI representation for the root node, or there's no DOM node, + // thus there's nothing to be done when the icon changes. + if (!elt || elt == this._rootElt) { + return; + } + + // Here we need the <menu>. + if (elt.localName == "menupopup") { + elt = elt.parentNode; + } + // We must remove and reset the attribute to force an update. + elt.removeAttribute("image"); + elt.setAttribute("image", aPlacesNode.icon); + } + + nodeTitleChanged(aPlacesNode, aNewTitle) { + let elt = this._getDOMNodeForPlacesNode(aPlacesNode); + + // There's no UI representation for the root node, thus there's + // nothing to be done when the title changes. + if (elt == this._rootElt) { + return; + } + + // Here we need the <menu>. + if (elt.localName == "menupopup") { + elt = elt.parentNode; + } + + if (!aNewTitle && elt.localName != "toolbarbutton") { + // Many users consider toolbars as shortcuts containers, so explicitly + // allow empty labels on toolbarbuttons. For any other element try to be + // smarter, guessing a title from the uri. + elt.setAttribute("label", PlacesUIUtils.getBestTitle(aPlacesNode)); + } else { + elt.setAttribute("label", aNewTitle); + } + } + + nodeRemoved(aParentPlacesNode, aPlacesNode, aIndex) { + let parentElt = this._getDOMNodeForPlacesNode(aParentPlacesNode); + let elt = this._getDOMNodeForPlacesNode(aPlacesNode); + + // Here we need the <menu>. + if (elt.localName == "menupopup") { + elt = elt.parentNode; + } + + if (parentElt._built) { + parentElt.removeChild(elt); + + // Figure out if we need to show the "<Empty>" menu-item. + // TODO Bug 517701: This doesn't seem to handle the case of an empty + // root. + if (parentElt._startMarker.nextElementSibling == parentElt._endMarker) { + this._setEmptyPopupStatus(parentElt, true); + } + } + } + + // Opt-out of history details updates, since all the views derived from this + // are not showing them. + skipHistoryDetailsNotifications = true; + nodeHistoryDetailsChanged() {} + nodeTagsChanged() {} + nodeDateAddedChanged() {} + nodeLastModifiedChanged() {} + nodeKeywordChanged() {} + sortingChanged() {} + batching() {} + + nodeInserted(aParentPlacesNode, aPlacesNode, aIndex) { + let parentElt = this._getDOMNodeForPlacesNode(aParentPlacesNode); + if (!parentElt._built) { + return; + } + + let index = + Array.prototype.indexOf.call(parentElt.children, parentElt._startMarker) + + aIndex + + 1; + this._insertNewItemToPopup( + aPlacesNode, + parentElt, + parentElt.children[index] || parentElt._endMarker + ); + this._setEmptyPopupStatus(parentElt, false); + } + + nodeMoved( + aPlacesNode, + aOldParentPlacesNode, + aOldIndex, + aNewParentPlacesNode, + aNewIndex + ) { + // Note: the current implementation of moveItem does not actually + // use this notification when the item in question is moved from one + // folder to another. Instead, it calls nodeRemoved and nodeInserted + // for the two folders. Thus, we can assume old-parent == new-parent. + let elt = this._getDOMNodeForPlacesNode(aPlacesNode); + + // Here we need the <menu>. + if (elt.localName == "menupopup") { + elt = elt.parentNode; + } + + // If our root node is a folder, it might be moved. There's nothing + // we need to do in that case. + if (elt == this._rootElt) { + return; + } + + let parentElt = this._getDOMNodeForPlacesNode(aNewParentPlacesNode); + if (parentElt._built) { + // Move the node. + parentElt.removeChild(elt); + let index = + Array.prototype.indexOf.call( + parentElt.children, + parentElt._startMarker + ) + + aNewIndex + + 1; + parentElt.insertBefore(elt, parentElt.children[index]); + } + } + + containerStateChanged(aPlacesNode, aOldState, aNewState) { + if ( + aNewState == Ci.nsINavHistoryContainerResultNode.STATE_OPENED || + aNewState == Ci.nsINavHistoryContainerResultNode.STATE_CLOSED + ) { + this.invalidateContainer(aPlacesNode); + } + } + + /** + * Checks whether the popup associated with the provided element is open. + * This method may be overridden by classes that extend this base class. + * + * @param {Element} elt + * The element to check. + * @returns {boolean} + */ + _isPopupOpen(elt) { + return !!elt.parentNode.open; + } + + invalidateContainer(aPlacesNode) { + let elt = this._getDOMNodeForPlacesNode(aPlacesNode); + elt._built = false; + + // If the menupopup is open we should live-update it. + if (this._isPopupOpen(elt)) { + this._rebuildPopup(elt); + } + } + + uninit() { + if (this._result) { + this._result.removeObserver(this); + this._resultNode.containerOpen = false; + this._resultNode = null; + this._result = null; + } + + if (this._controller) { + this._controller.terminate(); + // Removing the controller will fail if it is already no longer there. + // This can happen if the view element was removed/reinserted without + // our knowledge. There is no way to check for that having happened + // without the possibility of an exception. :-( + try { + this._viewElt.controllers.removeController(this._controller); + } catch (ex) { + } finally { + this._controller = null; + } + } + + delete this._viewElt._placesView; + } + + get isRTL() { + if ("_isRTL" in this) { + return this._isRTL; + } + + return (this._isRTL = + document.defaultView.getComputedStyle(this._viewElt).direction == "rtl"); + } + + get ownerWindow() { + return window; + } + + /** + * Adds an "Open All in Tabs" menuitem to the bottom of the popup. + * + * @param {object} aPopup + * a Places popup. + */ + _mayAddCommandsItems(aPopup) { + // The command items are never added to the root popup. + if (aPopup == this._rootElt) { + return; + } + + let hasMultipleURIs = false; + + // Check if the popup contains at least 2 menuitems with places nodes. + // We don't currently support opening multiple uri nodes when they are not + // populated by the result. + if (aPopup._placesNode.childCount > 0) { + let currentChild = aPopup.firstElementChild; + let numURINodes = 0; + while (currentChild) { + if (currentChild.localName == "menuitem" && currentChild._placesNode) { + if (++numURINodes == 2) { + break; + } + } + currentChild = currentChild.nextElementSibling; + } + hasMultipleURIs = numURINodes > 1; + } + + if (!hasMultipleURIs) { + // We don't have to show any option. + if (aPopup._endOptOpenAllInTabs) { + aPopup.removeChild(aPopup._endOptOpenAllInTabs); + aPopup._endOptOpenAllInTabs = null; + + aPopup.removeChild(aPopup._endOptSeparator); + aPopup._endOptSeparator = null; + } + } else if (!aPopup._endOptOpenAllInTabs) { + // Create a separator before options. + aPopup._endOptSeparator = document.createXULElement("menuseparator"); + aPopup._endOptSeparator.className = "bookmarks-actions-menuseparator"; + aPopup.appendChild(aPopup._endOptSeparator); + + // Add the "Open All in Tabs" menuitem. + aPopup._endOptOpenAllInTabs = document.createXULElement("menuitem"); + aPopup._endOptOpenAllInTabs.className = "openintabs-menuitem"; + + aPopup._endOptOpenAllInTabs.setAttribute( + "oncommand", + "PlacesUIUtils.openMultipleLinksInTabs(this.parentNode._placesNode, event, " + + "PlacesUIUtils.getViewForNode(this));" + ); + aPopup._endOptOpenAllInTabs.setAttribute( + "label", + gNavigatorBundle.getString("menuOpenAllInTabs.label") + ); + aPopup.appendChild(aPopup._endOptOpenAllInTabs); + } + } + + _ensureMarkers(aPopup) { + if (aPopup._startMarker) { + return; + } + + // Places nodes are appended between _startMarker and _endMarker, that + // are hidden menuseparators. By default they take the whole panel... + aPopup._startMarker = document.createXULElement("menuseparator"); + aPopup._startMarker.hidden = true; + aPopup.insertBefore(aPopup._startMarker, aPopup.firstElementChild); + aPopup._endMarker = document.createXULElement("menuseparator"); + aPopup._endMarker.hidden = true; + aPopup.appendChild(aPopup._endMarker); + + // ...but there can be static content before or after the places nodes, thus + // we move the markers to the right position, by checking for static content + // at the beginning of the view, and for an element with "afterplacescontent" + // attribute. + // TODO: In the future we should just use a container element. + let firstNonStaticNodeFound = false; + for (let child of aPopup.children) { + if (child.hasAttribute("afterplacescontent")) { + aPopup.insertBefore(aPopup._endMarker, child); + break; + } + + // Check for the first Places node that is not a view. + if (child._placesNode && !child._placesView && !firstNonStaticNodeFound) { + firstNonStaticNodeFound = true; + aPopup.insertBefore(aPopup._startMarker, child); + } + } + if (!firstNonStaticNodeFound) { + // Just put the start marker before the end marker. + aPopup.insertBefore(aPopup._startMarker, aPopup._endMarker); + } + } + + _onPopupShowing(aEvent) { + // Avoid handling popupshowing of inner views. + let popup = aEvent.originalTarget; + + this._ensureMarkers(popup); + + // Remove any delayed element, see _cleanPopup for details. + if ("_delayedRemovals" in popup) { + while (popup._delayedRemovals.length) { + popup.removeChild(popup._delayedRemovals.shift()); + } + } + + if (popup._placesNode && PlacesUIUtils.getViewForNode(popup) == this) { + if (!popup._placesNode.containerOpen) { + popup._placesNode.containerOpen = true; + } + if (!popup._built) { + this._rebuildPopup(popup); + } + + this._mayAddCommandsItems(popup); + } + } + + _addEventListeners(aObject, aEventNames, aCapturing = false) { + for (let i = 0; i < aEventNames.length; i++) { + aObject.addEventListener(aEventNames[i], this, aCapturing); + } + } + + _removeEventListeners(aObject, aEventNames, aCapturing = false) { + for (let i = 0; i < aEventNames.length; i++) { + aObject.removeEventListener(aEventNames[i], this, aCapturing); + } + } +} + +/** + * Toolbar View implementation. + */ +class PlacesToolbar extends PlacesViewBase { + constructor(placesUrl, rootElt, viewElt) { + let startTime = Date.now(); + super(placesUrl, rootElt, viewElt); + this._addEventListeners(this._dragRoot, this._cbEvents, false); + this._addEventListeners( + this._rootElt, + ["popupshowing", "popuphidden"], + true + ); + this._addEventListeners(this._rootElt, ["overflow", "underflow"], true); + this._addEventListeners(window, ["resize", "unload"], false); + + // If personal-bookmarks has been dragged to the tabs toolbar, + // we have to track addition and removals of tabs, to properly + // recalculate the available space for bookmarks. + // TODO (bug 734730): Use a performant mutation listener when available. + if ( + this._viewElt.parentNode.parentNode == + document.getElementById("TabsToolbar") + ) { + this._addEventListeners( + gBrowser.tabContainer, + ["TabOpen", "TabClose"], + false + ); + } + + Services.telemetry + .getHistogramById("FX_BOOKMARKS_TOOLBAR_INIT_MS") + .add(Date.now() - startTime); + } + + // Called by PlacesViewBase so we can init properties that class + // initialization depends on. PlacesViewBase will assign this.place which + // calls which sets `this.result` through its places observer, which changes + // containerOpen, which calls invalidateContainer(), which calls rebuild(), + // which needs `_overFolder`, `_chevronPopup` and various other things to + // exist. + _init() { + this._overFolder = { + elt: null, + openTimer: null, + hoverTime: 350, + closeTimer: null, + }; + + // Add some smart getters for our elements. + let thisView = this; + [ + ["_dropIndicator", "PlacesToolbarDropIndicator"], + ["_chevron", "PlacesChevron"], + ["_chevronPopup", "PlacesChevronPopup"], + ].forEach(function (elementGlobal) { + let [name, id] = elementGlobal; + thisView.__defineGetter__(name, function () { + let element = document.getElementById(id); + if (!element) { + return null; + } + + delete thisView[name]; + return (thisView[name] = element); + }); + }); + + this._viewElt._placesView = this; + + this._dragRoot = BookmarkingUI.toolbar.contains(this._viewElt) + ? BookmarkingUI.toolbar + : this._viewElt; + + this._updatingNodesVisibility = false; + } + + _cbEvents = [ + "dragstart", + "dragover", + "dragleave", + "dragend", + "drop", + "mousemove", + "mouseover", + "mouseout", + "mousedown", + ]; + + QueryInterface = ChromeUtils.generateQI([ + "nsINamed", + "nsITimerCallback", + ...PlacesViewBase.interfaces, + ]); + + uninit() { + if (this._dragRoot) { + this._removeEventListeners(this._dragRoot, this._cbEvents, false); + } + this._removeEventListeners( + this._rootElt, + ["popupshowing", "popuphidden"], + true + ); + this._removeEventListeners(this._rootElt, ["overflow", "underflow"], true); + this._removeEventListeners(window, ["resize", "unload"], false); + this._removeEventListeners( + gBrowser.tabContainer, + ["TabOpen", "TabClose"], + false + ); + + if (this._chevron._placesView) { + this._chevron._placesView.uninit(); + } + + if (this._otherBookmarks?._placesView) { + this._otherBookmarks._placesView.uninit(); + } + + super.uninit(); + } + + _openedMenuButton = null; + _allowPopupShowing = true; + + promiseRebuilt() { + return this._rebuilding?.promise; + } + + get _isAlive() { + return this._resultNode && this._rootElt; + } + + _runBeforeFrameRender(callback) { + return new Promise((resolve, reject) => { + window.requestAnimationFrame(() => { + try { + resolve(callback()); + } catch (err) { + reject(err); + } + }); + }); + } + + async _rebuild() { + // Clear out references to existing nodes, since they will be removed + // and re-added. + if (this._overFolder.elt) { + this._clearOverFolder(); + } + + this._openedMenuButton = null; + while (this._rootElt.hasChildNodes()) { + this._rootElt.firstChild.remove(); + } + + let cc = this._resultNode.childCount; + if (cc > 0) { + // There could be a lot of nodes, but we only want to build the ones that + // are more likely to be shown, not all of them. + // We also don't want to wait for reflows at every node insertion, to + // calculate a precise number of visible items, thus we guess a size from + // the first non-separator node (because separators have flexible size). + let startIndex = 0; + let limit = await this._runBeforeFrameRender(() => { + if (!this._isAlive) { + return cc; + } + + // Look for the first non-separator node. + let elt; + while (startIndex < cc) { + elt = this._insertNewItem( + this._resultNode.getChild(startIndex), + this._rootElt + ); + ++startIndex; + if (elt.localName != "toolbarseparator") { + break; + } + } + if (!elt) { + return cc; + } + + return window.promiseDocumentFlushed(() => { + // We assume a button with just the icon will be more or less a square, + // then compensate the measurement error by considering a larger screen + // width. Moreover the window could be bigger than the screen. + let size = elt.clientHeight || 1; // Sanity fallback. + return Math.min(cc, parseInt((window.screen.width * 1.5) / size)); + }); + }); + + if (!this._isAlive) { + return; + } + + let fragment = document.createDocumentFragment(); + for (let i = startIndex; i < limit; ++i) { + this._insertNewItem(this._resultNode.getChild(i), fragment); + } + await new Promise(resolve => window.requestAnimationFrame(resolve)); + if (!this._isAlive) { + return; + } + this._rootElt.appendChild(fragment); + this.updateNodesVisibility(); + } + + if (this._chevronPopup.hasAttribute("type")) { + // Chevron has already been initialized, but since we are forcing + // a rebuild of the toolbar, it has to be rebuilt. + // Otherwise, it will be initialized when the toolbar overflows. + this._chevronPopup.place = this.place; + } + + // Rebuild the "Other Bookmarks" folder if it already exists. + let otherBookmarks = document.getElementById("OtherBookmarks"); + otherBookmarks?.remove(); + + BookmarkingUI.maybeShowOtherBookmarksFolder().catch(console.error); + } + + _insertNewItem(aChild, aInsertionNode, aBefore = null) { + this._domNodes.delete(aChild); + + let type = aChild.type; + let button; + if (type == Ci.nsINavHistoryResultNode.RESULT_TYPE_SEPARATOR) { + button = document.createXULElement("toolbarseparator"); + } else { + button = document.createXULElement("toolbarbutton"); + button.className = "bookmark-item"; + button.setAttribute("label", aChild.title || ""); + + if (PlacesUtils.containerTypes.includes(type)) { + button.setAttribute("type", "menu"); + button.setAttribute("container", "true"); + + if (PlacesUtils.nodeIsQuery(aChild)) { + button.setAttribute("query", "true"); + if (PlacesUtils.nodeIsTagQuery(aChild)) { + button.setAttribute("tagContainer", "true"); + } + } + + let popup = document.createXULElement("menupopup", { + is: "places-popup", + }); + popup.setAttribute("placespopup", "true"); + popup.classList.add("toolbar-menupopup"); + button.appendChild(popup); + popup._placesNode = PlacesUtils.asContainer(aChild); + popup.setAttribute("context", "placesContext"); + + this._domNodes.set(aChild, popup); + } else if (PlacesUtils.nodeIsURI(aChild)) { + button.setAttribute( + "scheme", + PlacesUIUtils.guessUrlSchemeForUI(aChild.uri) + ); + } + } + + button._placesNode = aChild; + let { icon } = button._placesNode; + if (icon) { + button.setAttribute("image", icon); + } + if (!this._domNodes.has(aChild)) { + this._domNodes.set(aChild, button); + } + + if (aBefore) { + aInsertionNode.insertBefore(button, aBefore); + } else { + aInsertionNode.appendChild(button); + } + return button; + } + + _updateChevronPopupNodesVisibility() { + // Note the toolbar by default builds less nodes than the chevron popup. + for ( + let toolbarNode = this._rootElt.firstElementChild, + node = this._chevronPopup._startMarker.nextElementSibling; + toolbarNode && node; + toolbarNode = toolbarNode.nextElementSibling, + node = node.nextElementSibling + ) { + node.hidden = toolbarNode.style.visibility != "hidden"; + } + } + + _onChevronPopupShowing(aEvent) { + // Handle popupshowing only for the chevron popup, not for nested ones. + if (aEvent.target != this._chevronPopup) { + return; + } + + if (!this._chevron._placesView) { + this._chevron._placesView = new PlacesMenu(aEvent, this.place); + } + + this._updateChevronPopupNodesVisibility(); + } + + _onOtherBookmarksPopupShowing(aEvent) { + if (aEvent.target != this._otherBookmarksPopup) { + return; + } + + if (!this._otherBookmarks._placesView) { + this._otherBookmarks._placesView = new PlacesMenu( + aEvent, + "place:parent=" + PlacesUtils.bookmarks.unfiledGuid + ); + } + } + + handleEvent(aEvent) { + switch (aEvent.type) { + case "unload": + this.uninit(); + break; + case "resize": + // This handler updates nodes visibility in both the toolbar + // and the chevron popup when a window resize does not change + // the overflow status of the toolbar. + if (aEvent.target == aEvent.currentTarget) { + this.updateNodesVisibility(); + } + break; + case "overflow": + if (!this._isOverflowStateEventRelevant(aEvent)) { + return; + } + // Avoid triggering overflow in containers if possible + aEvent.stopPropagation(); + this._onOverflow(); + break; + case "underflow": + if (!this._isOverflowStateEventRelevant(aEvent)) { + return; + } + // Avoid triggering underflow in containers if possible + aEvent.stopPropagation(); + this._onUnderflow(); + break; + case "TabOpen": + case "TabClose": + this.updateNodesVisibility(); + break; + case "dragstart": + this._onDragStart(aEvent); + break; + case "dragover": + this._onDragOver(aEvent); + break; + case "dragleave": + this._onDragLeave(aEvent); + break; + case "dragend": + this._onDragEnd(aEvent); + break; + case "drop": + this._onDrop(aEvent); + break; + case "mouseover": + this._onMouseOver(aEvent); + break; + case "mousemove": + this._onMouseMove(aEvent); + break; + case "mouseout": + this._onMouseOut(aEvent); + break; + case "mousedown": + this._onMouseDown(aEvent); + break; + case "popupshowing": + this._onPopupShowing(aEvent); + break; + case "popuphidden": + this._onPopupHidden(aEvent); + break; + default: + throw new Error("Trying to handle unexpected event."); + } + } + + _isOverflowStateEventRelevant(aEvent) { + // Ignore events not aimed at ourselves, as well as purely vertical ones: + return aEvent.target == aEvent.currentTarget && aEvent.detail > 0; + } + + _onOverflow() { + // Attach the popup binding to the chevron popup if it has not yet + // been initialized. + if (!this._chevronPopup.hasAttribute("type")) { + this._chevronPopup.setAttribute("place", this.place); + this._chevronPopup.setAttribute("type", "places"); + } + this._chevron.collapsed = false; + this.updateNodesVisibility(); + } + + _onUnderflow() { + this.updateNodesVisibility(); + this._chevron.collapsed = true; + } + + updateNodesVisibility() { + // Update the chevron on a timer. This will avoid repeated work when + // lot of changes happen in a small timeframe. + if (this._updateNodesVisibilityTimer) { + this._updateNodesVisibilityTimer.cancel(); + } + + this._updateNodesVisibilityTimer = this._setTimer(100); + } + + async _updateNodesVisibilityTimerCallback() { + if (this._updatingNodesVisibility || window.closed) { + return; + } + this._updatingNodesVisibility = true; + + let dwu = window.windowUtils; + + let scrollRect = await window.promiseDocumentFlushed(() => + dwu.getBoundsWithoutFlushing(this._rootElt) + ); + + let childOverflowed = false; + + // We're about to potentially update a bunch of nodes, so we do it + // in a requestAnimationFrame so that other JS that's might execute + // in the same tick can avoid flushing styles and layout for these + // changes. + window.requestAnimationFrame(() => { + for (let child of this._rootElt.children) { + // Once a child overflows, all the next ones will. + if (!childOverflowed) { + let childRect = dwu.getBoundsWithoutFlushing(child); + childOverflowed = this.isRTL + ? childRect.left < scrollRect.left + : childRect.right > scrollRect.right; + } + + if (childOverflowed) { + child.removeAttribute("image"); + child.style.visibility = "hidden"; + } else { + let icon = child._placesNode.icon; + if (icon) { + child.setAttribute("image", icon); + } + child.style.removeProperty("visibility"); + } + } + + // We rebuild the chevron on popupShowing, so if it is open + // we must update it. + if (!this._chevron.collapsed && this._chevron.open) { + this._updateChevronPopupNodesVisibility(); + } + + let event = new CustomEvent("BookmarksToolbarVisibilityUpdated", { + bubbles: true, + }); + this._viewElt.dispatchEvent(event); + this._updatingNodesVisibility = false; + }); + } + + nodeInserted(aParentPlacesNode, aPlacesNode, aIndex) { + let parentElt = this._getDOMNodeForPlacesNode(aParentPlacesNode); + if (parentElt == this._rootElt) { + // Node is on the toolbar. + let children = this._rootElt.children; + // Nothing to do if it's a never-visible node, but note it's possible + // we are appending. + if (aIndex > children.length) { + return; + } + + // Note that childCount is already accounting for the node being added, + // thus we must subtract one node from it. + if (this._resultNode.childCount - 1 > children.length) { + if (aIndex == children.length) { + // If we didn't build all the nodes and new node is being appended, + // we can skip it as well. + return; + } + // Keep the number of built nodes consistent. + this._rootElt.removeChild(this._rootElt.lastElementChild); + } + + let button = this._insertNewItem( + aPlacesNode, + this._rootElt, + children[aIndex] || null + ); + let prevSiblingOverflowed = + aIndex > 0 && + aIndex <= children.length && + children[aIndex - 1].style.visibility == "hidden"; + if (prevSiblingOverflowed) { + button.style.visibility = "hidden"; + } else { + let icon = aPlacesNode.icon; + if (icon) { + button.setAttribute("image", icon); + } + this.updateNodesVisibility(); + } + return; + } + + super.nodeInserted(aParentPlacesNode, aPlacesNode, aIndex); + } + + nodeRemoved(aParentPlacesNode, aPlacesNode, aIndex) { + let parentElt = this._getDOMNodeForPlacesNode(aParentPlacesNode); + if (parentElt == this._rootElt) { + // Node is on the toolbar. + let elt = this._getDOMNodeForPlacesNode(aPlacesNode, true); + // Nothing to do if it's a never-visible node. + if (!elt) { + return; + } + + // Here we need the <menu>. + if (elt.localName == "menupopup") { + elt = elt.parentNode; + } + + let overflowed = elt.style.visibility == "hidden"; + this._removeChild(elt); + if (this._resultNode.childCount > this._rootElt.children.length) { + // A new node should be built to keep a coherent number of children. + this._insertNewItem( + this._resultNode.getChild(this._rootElt.children.length), + this._rootElt + ); + } + if (!overflowed) { + this.updateNodesVisibility(); + } + return; + } + + super.nodeRemoved(aParentPlacesNode, aPlacesNode, aIndex); + } + + nodeMoved( + aPlacesNode, + aOldParentPlacesNode, + aOldIndex, + aNewParentPlacesNode, + aNewIndex + ) { + let parentElt = this._getDOMNodeForPlacesNode(aNewParentPlacesNode); + if (parentElt == this._rootElt) { + // Node is on the toolbar. + // Do nothing if the node will never be visible. + let lastBuiltIndex = this._rootElt.children.length - 1; + if (aOldIndex > lastBuiltIndex && aNewIndex > lastBuiltIndex + 1) { + return; + } + + let elt = this._getDOMNodeForPlacesNode(aPlacesNode, true); + if (elt) { + // Here we need the <menu>. + if (elt.localName == "menupopup") { + elt = elt.parentNode; + } + this._removeChild(elt); + } + + if (aNewIndex > lastBuiltIndex + 1) { + if (this._resultNode.childCount > this._rootElt.children.length) { + // If the element was built and becomes non built, another node should + // be built to keep a coherent number of children. + this._insertNewItem( + this._resultNode.getChild(this._rootElt.children.length), + this._rootElt + ); + } + return; + } + + if (!elt) { + // The node has not been inserted yet, so we must create it. + elt = this._insertNewItem( + aPlacesNode, + this._rootElt, + this._rootElt.children[aNewIndex] + ); + let icon = aPlacesNode.icon; + if (icon) { + elt.setAttribute("image", icon); + } + } else { + this._rootElt.insertBefore(elt, this._rootElt.children[aNewIndex]); + } + + // The chevron view may get nodeMoved after the toolbar. In such a case, + // we should ensure (by manually swapping menuitems) that the actual nodes + // are in the final position before updateNodesVisibility tries to update + // their visibility, or the chevron may go out of sync. + // Luckily updateNodesVisibility runs on a timer, so, by the time it updates + // nodes, the menu has already handled the notification. + + this.updateNodesVisibility(); + return; + } + + super.nodeMoved( + aPlacesNode, + aOldParentPlacesNode, + aOldIndex, + aNewParentPlacesNode, + aNewIndex + ); + } + + nodeTitleChanged(aPlacesNode, aNewTitle) { + let elt = this._getDOMNodeForPlacesNode(aPlacesNode, true); + + // Nothing to do if it's a never-visible node. + if (!elt || elt == this._rootElt) { + return; + } + + super.nodeTitleChanged(aPlacesNode, aNewTitle); + + // Here we need the <menu>. + if (elt.localName == "menupopup") { + elt = elt.parentNode; + } + + if (elt.parentNode == this._rootElt) { + // Node is on the toolbar. + if (elt.style.visibility != "hidden") { + this.updateNodesVisibility(); + } + } + } + + invalidateContainer(aPlacesNode) { + let elt = this._getDOMNodeForPlacesNode(aPlacesNode, true); + // Nothing to do if it's a never-visible node. + if (!elt) { + return; + } + + if (elt == this._rootElt) { + // Container is the toolbar itself. + let instance = (this._rebuildingInstance = {}); + if (!this._rebuilding) { + this._rebuilding = Promise.withResolvers(); + } + this._rebuild() + .catch(console.error) + .finally(() => { + if (instance == this._rebuildingInstance) { + this._rebuilding.resolve(); + this._rebuilding = null; + } + }); + return; + } + + super.invalidateContainer(aPlacesNode); + } + + _clearOverFolder() { + // The mouse is no longer dragging over the stored menubutton. + // Close the menubutton, clear out drag styles, and clear all + // timers for opening/closing it. + if (this._overFolder.elt && this._overFolder.elt.menupopup) { + if (!this._overFolder.elt.menupopup.hasAttribute("dragover")) { + this._overFolder.elt.menupopup.hidePopup(); + } + this._overFolder.elt.removeAttribute("dragover"); + this._overFolder.elt = null; + } + if (this._overFolder.openTimer) { + this._overFolder.openTimer.cancel(); + this._overFolder.openTimer = null; + } + if (this._overFolder.closeTimer) { + this._overFolder.closeTimer.cancel(); + this._overFolder.closeTimer = null; + } + } + + /** + * This function returns information about where to drop when dragging over + * the toolbar. + * + * @param {object} aEvent + * The associated event. + * @returns {object} + * - ip: the insertion point for the bookmarks service. + * - beforeIndex: child index to drop before, for the drop indicator. + * - folderElt: the folder to drop into, if applicable. + */ + _getDropPoint(aEvent) { + if (!PlacesUtils.nodeIsFolder(this._resultNode)) { + return null; + } + + let dropPoint = { ip: null, beforeIndex: null, folderElt: null }; + let elt = aEvent.target; + if ( + elt._placesNode && + elt != this._rootElt && + elt.localName != "menupopup" + ) { + let eltRect = elt.getBoundingClientRect(); + let eltIndex = Array.prototype.indexOf.call(this._rootElt.children, elt); + if ( + PlacesUtils.nodeIsFolder(elt._placesNode) && + !PlacesUIUtils.isFolderReadOnly(elt._placesNode) + ) { + // This is a folder. + // If we are in the middle of it, drop inside it. + // Otherwise, drop before it, with regards to RTL mode. + let threshold = eltRect.width * 0.25; + if ( + this.isRTL + ? aEvent.clientX > eltRect.right - threshold + : aEvent.clientX < eltRect.left + threshold + ) { + // Drop before this folder. + dropPoint.ip = new PlacesInsertionPoint({ + parentGuid: PlacesUtils.getConcreteItemGuid(this._resultNode), + index: eltIndex, + orientation: Ci.nsITreeView.DROP_BEFORE, + }); + dropPoint.beforeIndex = eltIndex; + } else if ( + this.isRTL + ? aEvent.clientX > eltRect.left + threshold + : aEvent.clientX < eltRect.right - threshold + ) { + // Drop inside this folder. + let tagName = PlacesUtils.nodeIsTagQuery(elt._placesNode) + ? elt._placesNode.title + : null; + dropPoint.ip = new PlacesInsertionPoint({ + parentGuid: PlacesUtils.getConcreteItemGuid(elt._placesNode), + tagName, + }); + dropPoint.beforeIndex = eltIndex; + dropPoint.folderElt = elt; + } else { + // Drop after this folder. + let beforeIndex = + eltIndex == this._rootElt.children.length - 1 ? -1 : eltIndex + 1; + + dropPoint.ip = new PlacesInsertionPoint({ + parentGuid: PlacesUtils.getConcreteItemGuid(this._resultNode), + index: beforeIndex, + orientation: Ci.nsITreeView.DROP_BEFORE, + }); + dropPoint.beforeIndex = beforeIndex; + } + } else { + // This is a non-folder node or a read-only folder. + // Drop before it with regards to RTL mode. + let threshold = eltRect.width * 0.5; + if ( + this.isRTL + ? aEvent.clientX > eltRect.left + threshold + : aEvent.clientX < eltRect.left + threshold + ) { + // Drop before this bookmark. + dropPoint.ip = new PlacesInsertionPoint({ + parentGuid: PlacesUtils.getConcreteItemGuid(this._resultNode), + index: eltIndex, + orientation: Ci.nsITreeView.DROP_BEFORE, + }); + dropPoint.beforeIndex = eltIndex; + } else { + // Drop after this bookmark. + let beforeIndex = + eltIndex == this._rootElt.children.length - 1 ? -1 : eltIndex + 1; + dropPoint.ip = new PlacesInsertionPoint({ + parentGuid: PlacesUtils.getConcreteItemGuid(this._resultNode), + index: beforeIndex, + orientation: Ci.nsITreeView.DROP_BEFORE, + }); + dropPoint.beforeIndex = beforeIndex; + } + } + } else { + // We are most likely dragging on the empty area of the + // toolbar, we should drop after the last node. + dropPoint.ip = new PlacesInsertionPoint({ + parentGuid: PlacesUtils.getConcreteItemGuid(this._resultNode), + orientation: Ci.nsITreeView.DROP_BEFORE, + }); + dropPoint.beforeIndex = -1; + } + + return dropPoint; + } + + _setTimer(aTime) { + let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + timer.initWithCallback(this, aTime, timer.TYPE_ONE_SHOT); + return timer; + } + + get name() { + return "PlacesToolbar"; + } + + notify(aTimer) { + if (aTimer == this._updateNodesVisibilityTimer) { + this._updateNodesVisibilityTimer = null; + this._updateNodesVisibilityTimerCallback(); + } else if (aTimer == this._overFolder.openTimer) { + // * Timer to open a menubutton that's being dragged over. + // Set the autoopen attribute on the folder's menupopup so that + // the menu will automatically close when the mouse drags off of it. + this._overFolder.elt.menupopup.setAttribute("autoopened", "true"); + this._overFolder.elt.open = true; + this._overFolder.openTimer = null; + } else if (aTimer == this._overFolder.closeTimer) { + // * Timer to close a menubutton that's been dragged off of. + // Close the menubutton if we are not dragging over it or one of + // its children. The autoopened attribute will let the menu know to + // close later if the menu is still being dragged over. + let currentPlacesNode = PlacesControllerDragHelper.currentDropTarget; + let inHierarchy = false; + while (currentPlacesNode) { + if (currentPlacesNode == this._rootElt) { + inHierarchy = true; + break; + } + currentPlacesNode = currentPlacesNode.parentNode; + } + // The _clearOverFolder() function will close the menu for + // _overFolder.elt. So null it out if we don't want to close it. + if (inHierarchy) { + this._overFolder.elt = null; + } + + // Clear out the folder and all associated timers. + this._clearOverFolder(); + } + } + + _onMouseOver(aEvent) { + let button = aEvent.target; + if ( + button.parentNode == this._rootElt && + button._placesNode && + PlacesUtils.nodeIsURI(button._placesNode) + ) { + window.XULBrowserWindow.setOverLink(aEvent.target._placesNode.uri); + } + } + + _onMouseOut(aEvent) { + window.XULBrowserWindow.setOverLink(""); + } + + _onMouseDown(aEvent) { + let target = aEvent.target; + if ( + aEvent.button == 0 && + target.localName == "toolbarbutton" && + target.getAttribute("type") == "menu" + ) { + let modifKey = aEvent.shiftKey || aEvent.getModifierState("Accel"); + if (modifKey) { + // Do not open the popup since BEH_onClick is about to + // open all child uri nodes in tabs. + this._allowPopupShowing = false; + } + } + if (target._placesNode?.uri) { + PlacesUIUtils.setupSpeculativeConnection(target._placesNode.uri, window); + } + } + + _cleanupDragDetails() { + // Called on dragend and drop. + PlacesControllerDragHelper.currentDropTarget = null; + this._draggedElt = null; + this._dropIndicator.collapsed = true; + } + + _onDragStart(aEvent) { + // Sub menus have their own d&d handlers. + let draggedElt = aEvent.target; + if (draggedElt.parentNode != this._rootElt || !draggedElt._placesNode) { + return; + } + + if ( + draggedElt.localName == "toolbarbutton" && + draggedElt.getAttribute("type") == "menu" + ) { + // If the drag gesture on a container is toward down we open instead + // of dragging. + let translateY = this._cachedMouseMoveEvent.clientY - aEvent.clientY; + let translateX = this._cachedMouseMoveEvent.clientX - aEvent.clientX; + if (translateY >= Math.abs(translateX / 2)) { + // Don't start the drag. + aEvent.preventDefault(); + // Open the menu. + draggedElt.open = true; + return; + } + + // If the menu is open, close it. + if (draggedElt.open) { + draggedElt.menupopup.hidePopup(); + draggedElt.open = false; + } + } + + // Activate the view and cache the dragged element. + this._draggedElt = draggedElt._placesNode; + this._rootElt.focus(); + + this._controller.setDataTransfer(aEvent); + aEvent.stopPropagation(); + } + + _onDragOver(aEvent) { + // Cache the dataTransfer + PlacesControllerDragHelper.currentDropTarget = aEvent.target; + let dt = aEvent.dataTransfer; + + let dropPoint = this._getDropPoint(aEvent); + if ( + !dropPoint || + !dropPoint.ip || + !PlacesControllerDragHelper.canDrop(dropPoint.ip, dt) + ) { + this._dropIndicator.collapsed = true; + aEvent.stopPropagation(); + return; + } + + if (dropPoint.folderElt || aEvent.originalTarget == this._chevron) { + // Dropping over a menubutton or chevron button. + // Set styles and timer to open relative menupopup. + let overElt = dropPoint.folderElt || this._chevron; + if (this._overFolder.elt != overElt) { + this._clearOverFolder(); + this._overFolder.elt = overElt; + this._overFolder.openTimer = this._setTimer(this._overFolder.hoverTime); + } + if (!this._overFolder.elt.hasAttribute("dragover")) { + this._overFolder.elt.setAttribute("dragover", "true"); + } + + this._dropIndicator.collapsed = true; + } else { + // Dragging over a normal toolbarbutton, + // show indicator bar and move it to the appropriate drop point. + let ind = this._dropIndicator; + ind.parentNode.collapsed = false; + let halfInd = ind.clientWidth / 2; + let translateX; + if (this.isRTL) { + halfInd = Math.ceil(halfInd); + translateX = 0 - this._rootElt.getBoundingClientRect().right - halfInd; + if (this._rootElt.firstElementChild) { + if (dropPoint.beforeIndex == -1) { + translateX += + this._rootElt.lastElementChild.getBoundingClientRect().left; + } else { + translateX += + this._rootElt.children[ + dropPoint.beforeIndex + ].getBoundingClientRect().right; + } + } + } else { + halfInd = Math.floor(halfInd); + translateX = 0 - this._rootElt.getBoundingClientRect().left + halfInd; + if (this._rootElt.firstElementChild) { + if (dropPoint.beforeIndex == -1) { + translateX += + this._rootElt.lastElementChild.getBoundingClientRect().right; + } else { + translateX += + this._rootElt.children[ + dropPoint.beforeIndex + ].getBoundingClientRect().left; + } + } + } + + ind.style.transform = "translate(" + Math.round(translateX) + "px)"; + ind.style.marginInlineStart = -ind.clientWidth + "px"; + ind.collapsed = false; + + // Clear out old folder information. + this._clearOverFolder(); + } + + aEvent.preventDefault(); + aEvent.stopPropagation(); + } + + _onDrop(aEvent) { + PlacesControllerDragHelper.currentDropTarget = aEvent.target; + + let dropPoint = this._getDropPoint(aEvent); + if (dropPoint && dropPoint.ip) { + PlacesControllerDragHelper.onDrop( + dropPoint.ip, + aEvent.dataTransfer + ).catch(console.error); + aEvent.preventDefault(); + } + + this._cleanupDragDetails(); + aEvent.stopPropagation(); + } + + _onDragLeave(aEvent) { + PlacesControllerDragHelper.currentDropTarget = null; + + this._dropIndicator.collapsed = true; + + // If we hovered over a folder, close it now. + if (this._overFolder.elt) { + this._overFolder.closeTimer = this._setTimer(this._overFolder.hoverTime); + } + } + + _onDragEnd(aEvent) { + this._cleanupDragDetails(); + } + + _onPopupShowing(aEvent) { + if (!this._allowPopupShowing) { + this._allowPopupShowing = true; + aEvent.preventDefault(); + return; + } + + let parent = aEvent.target.parentNode; + if (parent.localName == "toolbarbutton") { + this._openedMenuButton = parent; + } + + super._onPopupShowing(aEvent); + } + + _onPopupHidden(aEvent) { + let popup = aEvent.target; + let placesNode = popup._placesNode; + // Avoid handling popuphidden of inner views + if ( + placesNode && + PlacesUIUtils.getViewForNode(popup) == this && + // UI performance: folder queries are cheap, keep the resultnode open + // so we don't rebuild its contents whenever the popup is reopened. + !PlacesUtils.nodeIsFolder(placesNode) + ) { + placesNode.containerOpen = false; + } + + let parent = popup.parentNode; + if (parent.localName == "toolbarbutton") { + this._openedMenuButton = null; + // Clear the dragover attribute if present, if we are dragging into a + // folder in the hierachy of current opened popup we don't clear + // this attribute on clearOverFolder. See Notify for closeTimer. + if (parent.hasAttribute("dragover")) { + parent.removeAttribute("dragover"); + } + } + } + + _onMouseMove(aEvent) { + // Used in dragStart to prevent dragging folders when dragging down. + this._cachedMouseMoveEvent = aEvent; + + if ( + this._openedMenuButton == null || + PlacesControllerDragHelper.getSession() + ) { + return; + } + + let target = aEvent.originalTarget; + if ( + this._openedMenuButton != target && + target.localName == "toolbarbutton" && + target.type == "menu" + ) { + this._openedMenuButton.open = false; + target.open = true; + } + } +} + +/** + * View for Places menus. This object should be created during the first + * popupshowing that's dispatched on the menu. + * + */ +class PlacesMenu extends PlacesViewBase { + /** + * + * @param {Event} popupShowingEvent + * The event associated with opening the menu. + * @param {string} placesUrl + * The query associated with the view on the menu. + */ + constructor(popupShowingEvent, placesUrl) { + super( + placesUrl, + popupShowingEvent.target, // <menupopup> + popupShowingEvent.target.parentNode // <menu> + ); + + this._addEventListeners( + this._rootElt, + ["popupshowing", "popuphidden"], + true + ); + this._addEventListeners(window, ["unload"], false); + this._addEventListeners(this._rootElt, ["mousedown"], false); + if (AppConstants.platform === "macosx") { + // Must walk up to support views in sub-menus, like Bookmarks Toolbar menu. + for (let elt = this._viewElt.parentNode; elt; elt = elt.parentNode) { + if (elt.localName == "menubar") { + this._nativeView = true; + break; + } + } + } + + this._onPopupShowing(popupShowingEvent); + } + + _init() { + this._viewElt._placesView = this; + } + + _removeChild(aChild) { + super._removeChild(aChild); + } + + uninit() { + this._removeEventListeners( + this._rootElt, + ["popupshowing", "popuphidden"], + true + ); + this._removeEventListeners(window, ["unload"], false); + this._removeEventListeners(this._rootElt, ["mousedown"], false); + + super.uninit(); + } + + handleEvent(aEvent) { + switch (aEvent.type) { + case "unload": + this.uninit(); + break; + case "popupshowing": + this._onPopupShowing(aEvent); + break; + case "popuphidden": + this._onPopupHidden(aEvent); + break; + case "mousedown": + this._onMouseDown(aEvent); + break; + } + } + + _onPopupHidden(aEvent) { + // Avoid handling popuphidden of inner views. + let popup = aEvent.originalTarget; + let placesNode = popup._placesNode; + if (!placesNode || PlacesUIUtils.getViewForNode(popup) != this) { + return; + } + + // UI performance: folder queries are cheap, keep the resultnode open + // so we don't rebuild its contents whenever the popup is reopened. + if (!PlacesUtils.nodeIsFolder(placesNode)) { + placesNode.containerOpen = false; + } + + // The autoopened attribute is set for folders which have been + // automatically opened when dragged over. Turn off this attribute + // when the folder closes because it is no longer applicable. + popup.removeAttribute("autoopened"); + popup.removeAttribute("dragstart"); + } + + // We don't have a facility for catch "mousedown" events on the native + // Mac menus because Mac doesn't expose it + _onMouseDown(aEvent) { + let target = aEvent.target; + if (target._placesNode?.uri) { + PlacesUIUtils.setupSpeculativeConnection(target._placesNode.uri, window); + } + } +} + +// This is used from CustomizableWidgets.sys.mjs using a `window` reference, +// so we have to expose this on the global. +this.PlacesPanelview = class PlacesPanelview extends PlacesViewBase { + constructor(placeUrl, rootElt, viewElt) { + super(placeUrl, rootElt, viewElt); + this._viewElt._placesView = this; + // We're simulating a popup show, because a panelview may only be shown when + // its containing popup is already shown. + this._onPopupShowing({ originalTarget: this._rootElt }); + this._addEventListeners(window, ["unload"]); + this._rootElt.setAttribute("context", "placesContext"); + } + + get events() { + if (this._events) { + return this._events; + } + return (this._events = [ + "click", + "command", + "dragend", + "dragstart", + "ViewHiding", + "ViewShown", + ]); + } + + handleEvent(event) { + switch (event.type) { + case "click": + // For middle clicks, fall through to the command handler. + if (event.button != 1) { + break; + } + // fall through + case "command": + this._onCommand(event); + break; + case "dragend": + this._onDragEnd(event); + break; + case "dragstart": + this._onDragStart(event); + break; + case "unload": + this.uninit(event); + break; + case "ViewHiding": + this._onPopupHidden(event); + break; + case "ViewShown": + this._onViewShown(event); + break; + } + } + + _onCommand(event) { + event = getRootEvent(event); + let button = event.originalTarget; + if (!button._placesNode) { + return; + } + + let modifKey = + AppConstants.platform === "macosx" ? event.metaKey : event.ctrlKey; + if (!PlacesUIUtils.openInTabClosesMenu && modifKey) { + // If 'Recent Bookmarks' in Bookmarks Panel. + if (button.parentNode.id == "panelMenu_bookmarksMenu") { + button.setAttribute("closemenu", "none"); + } + } else { + button.removeAttribute("closemenu"); + } + PlacesUIUtils.openNodeWithEvent(button._placesNode, event); + // Unlike left-click, middle-click requires manual menu closing. + if ( + button.parentNode.id != "panelMenu_bookmarksMenu" || + (event.type == "click" && + event.button == 1 && + PlacesUIUtils.openInTabClosesMenu) + ) { + this.panelMultiView.closest("panel").hidePopup(); + } + } + + _onDragEnd() { + this._draggedElt = null; + } + + _onDragStart(event) { + let draggedElt = event.originalTarget; + if (draggedElt.parentNode != this._rootElt || !draggedElt._placesNode) { + return; + } + + // Activate the view and cache the dragged element. + this._draggedElt = draggedElt._placesNode; + this._rootElt.focus(); + + this._controller.setDataTransfer(event); + event.stopPropagation(); + } + + uninit(event) { + this._removeEventListeners(this.panelMultiView, this.events); + this._removeEventListeners(window, ["unload"]); + delete this.panelMultiView; + super.uninit(event); + } + + _createDOMNodeForPlacesNode(placesNode) { + this._domNodes.delete(placesNode); + + let element; + let type = placesNode.type; + if (type == Ci.nsINavHistoryResultNode.RESULT_TYPE_SEPARATOR) { + element = document.createXULElement("toolbarseparator"); + } else { + if (type != Ci.nsINavHistoryResultNode.RESULT_TYPE_URI) { + throw new Error("Unexpected node"); + } + + element = document.createXULElement("toolbarbutton"); + element.classList.add( + "subviewbutton", + "subviewbutton-iconic", + "bookmark-item" + ); + element.setAttribute( + "scheme", + PlacesUIUtils.guessUrlSchemeForUI(placesNode.uri) + ); + element.setAttribute("label", PlacesUIUtils.getBestTitle(placesNode)); + + let icon = placesNode.icon; + if (icon) { + element.setAttribute("image", icon); + } + } + + element._placesNode = placesNode; + if (!this._domNodes.has(placesNode)) { + this._domNodes.set(placesNode, element); + } + + return element; + } + + _setEmptyPopupStatus(panelview, empty = false) { + if (!panelview._emptyMenuitem) { + panelview._emptyMenuitem = document.createXULElement("toolbarbutton"); + panelview._emptyMenuitem.setAttribute("disabled", true); + panelview._emptyMenuitem.className = "subviewbutton"; + document.l10n.setAttributes( + panelview._emptyMenuitem, + "places-empty-bookmarks-folder" + ); + } + + if (empty) { + panelview.setAttribute("emptyplacesresult", "true"); + // Don't add the menuitem if there is static content. + // We also support external usage for custom crafted panels - which'll have + // no markers present. + if ( + !panelview._startMarker || + (!panelview._startMarker.previousElementSibling && + !panelview._endMarker.nextElementSibling) + ) { + panelview.insertBefore(panelview._emptyMenuitem, panelview._endMarker); + } + } else { + panelview.removeAttribute("emptyplacesresult"); + try { + panelview.removeChild(panelview._emptyMenuitem); + } catch (ex) {} + } + } + + _isPopupOpen() { + return PanelView.forNode(this._viewElt).active; + } + + _onPopupHidden(event) { + let panelview = event.originalTarget; + let placesNode = panelview._placesNode; + // Avoid handling ViewHiding of inner views + if ( + placesNode && + PlacesUIUtils.getViewForNode(panelview) == this && + // UI performance: folder queries are cheap, keep the resultnode open + // so we don't rebuild its contents whenever the popup is reopened. + !PlacesUtils.nodeIsFolder(placesNode) + ) { + placesNode.containerOpen = false; + } + } + + _onPopupShowing(event) { + // If the event came from the root element, this is the first time + // we ever get here. + if (event.originalTarget == this._rootElt) { + // Start listening for events from all panels inside the panelmultiview. + this.panelMultiView = this._viewElt.panelMultiView; + this._addEventListeners(this.panelMultiView, this.events); + } + super._onPopupShowing(event); + } + + _onViewShown(event) { + if (event.originalTarget != this._viewElt) { + return; + } + + // Because PanelMultiView reparents the panelview internally, the controller + // may get lost. In that case we'll append it again, because we certainly + // need it later! + if (!this.controllers.getControllerCount() && this._controller) { + this.controllers.appendController(this._controller); + } + } +}; diff --git a/browser/components/places/content/controller.js b/browser/components/places/content/controller.js new file mode 100644 index 0000000000..3c113137df --- /dev/null +++ b/browser/components/places/content/controller.js @@ -0,0 +1,1745 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* 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/. */ + +ChromeUtils.defineESModuleGetters(this, { + PlacesTransactions: "resource://gre/modules/PlacesTransactions.sys.mjs", + PlacesUIUtils: "resource:///modules/PlacesUIUtils.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", +}); + +/* import-globals-from /browser/base/content/utilityOverlay.js */ +/* import-globals-from ./places.js */ + +/** + * Represents an insertion point within a container where we can insert + * items. + * + * @param {object} options an object containing the following properties: + * @param {string} options.parentGuid + * The unique identifier of the parent container + * @param {number} [options.index] + * The index within the container where to insert, defaults to appending + * @param {number} [options.orientation] + * The orientation of the insertion. NOTE: the adjustments to the + * insertion point to accommodate the orientation should be done by + * the person who constructs the IP, not the user. The orientation + * is provided for informational purposes only! Defaults to DROP_ON. + * @param {string} [options.tagName] + * The tag name if this IP is set to a tag, null otherwise. + * @param {*} [options.dropNearNode] + * When defined index will be calculated based on this node + */ +function PlacesInsertionPoint({ + parentGuid, + index = PlacesUtils.bookmarks.DEFAULT_INDEX, + orientation = Ci.nsITreeView.DROP_ON, + tagName = null, + dropNearNode = null, +}) { + this.guid = parentGuid; + this._index = index; + this.orientation = orientation; + this.tagName = tagName; + this.dropNearNode = dropNearNode; +} + +PlacesInsertionPoint.prototype = { + set index(val) { + this._index = val; + }, + + async getIndex() { + if (this.dropNearNode) { + // If dropNearNode is set up we must calculate the index of the item near + // which we will drop. + let index = ( + await PlacesUtils.bookmarks.fetch(this.dropNearNode.bookmarkGuid) + ).index; + return this.orientation == Ci.nsITreeView.DROP_BEFORE ? index : index + 1; + } + return this._index; + }, + + get isTag() { + return typeof this.tagName == "string"; + }, +}; + +/** + * Places Controller + */ + +function PlacesController(aView) { + this._view = aView; + ChromeUtils.defineLazyGetter(this, "profileName", function () { + return Services.dirsvc.get("ProfD", Ci.nsIFile).leafName; + }); + + XPCOMUtils.defineLazyPreferenceGetter( + this, + "forgetSiteClearByBaseDomain", + "places.forgetThisSite.clearByBaseDomain", + false + ); + ChromeUtils.defineESModuleGetters(this, { + ForgetAboutSite: "resource://gre/modules/ForgetAboutSite.sys.mjs", + }); +} + +PlacesController.prototype = { + /** + * The places view. + */ + _view: null, + + // This is used in certain views to disable user actions on the places tree + // views. This avoids accidental deletion/modification when the user is not + // actually organising the trees. + disableUserActions: false, + + QueryInterface: ChromeUtils.generateQI(["nsIClipboardOwner"]), + + // nsIClipboardOwner + LosingOwnership: function PC_LosingOwnership(aXferable) { + this.cutNodes = []; + }, + + terminate: function PC_terminate() { + this._releaseClipboardOwnership(); + }, + + supportsCommand: function PC_supportsCommand(aCommand) { + if (this.disableUserActions) { + return false; + } + // Non-Places specific commands that we also support + switch (aCommand) { + case "cmd_undo": + case "cmd_redo": + case "cmd_cut": + case "cmd_copy": + case "cmd_paste": + case "cmd_delete": + case "cmd_selectAll": + return true; + } + + // All other Places Commands are prefixed with "placesCmd_" ... this + // filters out other commands that we do _not_ support (see 329587). + const CMD_PREFIX = "placesCmd_"; + return aCommand.substr(0, CMD_PREFIX.length) == CMD_PREFIX; + }, + + isCommandEnabled: function PC_isCommandEnabled(aCommand) { + // Determine whether or not nodes can be inserted. + let ip = this._view.insertionPoint; + let canInsert = ip && (aCommand.endsWith("_paste") || !ip.isTag); + + switch (aCommand) { + case "cmd_undo": + return PlacesTransactions.topUndoEntry != null; + case "cmd_redo": + return PlacesTransactions.topRedoEntry != null; + case "cmd_cut": + case "placesCmd_cut": + for (let node of this._view.selectedNodes) { + // If selection includes history nodes or tags-as-bookmark, disallow + // cutting. + if ( + node.itemId == -1 || + (node.parent && PlacesUtils.nodeIsTagQuery(node.parent)) + ) { + return false; + } + } + // Otherwise fall through the cmd_delete check. + case "cmd_delete": + case "placesCmd_delete": + case "placesCmd_deleteDataHost": + return this._hasRemovableSelection(); + case "cmd_copy": + case "placesCmd_copy": + case "placesCmd_showInFolder": + return this._view.hasSelection; + case "cmd_paste": + case "placesCmd_paste": + // If the clipboard contains a Places flavor it is definitely pasteable, + // otherwise we also allow pasting "text/plain" and "text/x-moz-url" data. + // We don't check if the data is valid here, because the clipboard may + // contain very large blobs that would largely slowdown commands updating. + // Of course later paste() should ignore any invalid data. + return ( + canInsert && + Services.clipboard.hasDataMatchingFlavors( + [ + ...PlacesUIUtils.PLACES_FLAVORS, + PlacesUtils.TYPE_X_MOZ_URL, + PlacesUtils.TYPE_PLAINTEXT, + ], + Ci.nsIClipboard.kGlobalClipboard + ) + ); + case "cmd_selectAll": + if (this._view.selType != "single") { + let rootNode = this._view.result.root; + if (rootNode.containerOpen && rootNode.childCount > 0) { + return true; + } + } + return false; + case "placesCmd_open": + case "placesCmd_open:window": + case "placesCmd_open:privatewindow": + case "placesCmd_open:tab": { + let selectedNode = this._view.selectedNode; + return selectedNode && PlacesUtils.nodeIsURI(selectedNode); + } + case "placesCmd_new:folder": + return canInsert; + case "placesCmd_new:bookmark": + return canInsert; + case "placesCmd_new:separator": + return ( + canInsert && + !PlacesUtils.asQuery(this._view.result.root).queryOptions + .excludeItems && + this._view.result.sortingMode == + Ci.nsINavHistoryQueryOptions.SORT_BY_NONE + ); + case "placesCmd_show:info": { + let selectedNode = this._view.selectedNode; + return ( + selectedNode && + !PlacesUtils.isRootItem( + PlacesUtils.getConcreteItemGuid(selectedNode) + ) && + (PlacesUtils.nodeIsTagQuery(selectedNode) || + PlacesUtils.nodeIsBookmark(selectedNode) || + (PlacesUtils.nodeIsFolder(selectedNode) && + !PlacesUtils.isQueryGeneratedFolder(selectedNode))) + ); + } + case "placesCmd_sortBy:name": { + let selectedNode = this._view.selectedNode; + return ( + selectedNode && + PlacesUtils.nodeIsFolder(selectedNode) && + !PlacesUIUtils.isFolderReadOnly(selectedNode) && + this._view.result.sortingMode == + Ci.nsINavHistoryQueryOptions.SORT_BY_NONE + ); + } + case "placesCmd_createBookmark": { + return !this._view.selectedNodes.some( + node => !PlacesUtils.nodeIsURI(node) || node.itemId != -1 + ); + } + default: + return false; + } + }, + + doCommand: function PC_doCommand(aCommand) { + if (aCommand != "cmd_delete" && aCommand != "placesCmd_delete") { + // Clear out last removal fingerprint if any other commands arrives. + // This covers sequences like: remove, undo, remove, where the removal + // commands are not immediately adjacent. + this._lastRemoveOperationFingerprint = null; + } + switch (aCommand) { + case "cmd_undo": + PlacesTransactions.undo().catch(console.error); + break; + case "cmd_redo": + PlacesTransactions.redo().catch(console.error); + break; + case "cmd_cut": + case "placesCmd_cut": + this.cut(); + break; + case "cmd_copy": + case "placesCmd_copy": + this.copy(); + break; + case "cmd_paste": + case "placesCmd_paste": + this.paste().catch(console.error); + break; + case "cmd_delete": + case "placesCmd_delete": + this.remove("Remove Selection").catch(console.error); + break; + case "placesCmd_deleteDataHost": + this.forgetAboutThisSite().catch(console.error); + break; + case "cmd_selectAll": + this.selectAll(); + break; + case "placesCmd_open": + PlacesUIUtils.openNodeIn( + this._view.selectedNode, + "current", + this._view + ); + break; + case "placesCmd_open:window": + PlacesUIUtils.openNodeIn(this._view.selectedNode, "window", this._view); + break; + case "placesCmd_open:privatewindow": + PlacesUIUtils.openNodeIn( + this._view.selectedNode, + "window", + this._view, + true + ); + break; + case "placesCmd_open:tab": + PlacesUIUtils.openNodeIn(this._view.selectedNode, "tab", this._view); + break; + case "placesCmd_new:folder": + this.newItem("folder").catch(console.error); + break; + case "placesCmd_new:bookmark": + this.newItem("bookmark").catch(console.error); + break; + case "placesCmd_new:separator": + this.newSeparator().catch(console.error); + break; + case "placesCmd_show:info": + this.showBookmarkPropertiesForSelection(); + break; + case "placesCmd_sortBy:name": + this.sortFolderByName().catch(console.error); + break; + case "placesCmd_createBookmark": { + const nodes = this._view.selectedNodes.map(node => { + return { + uri: Services.io.newURI(node.uri), + title: node.title, + }; + }); + PlacesUIUtils.showBookmarkPagesDialog( + nodes, + ["keyword", "location"], + window.top + ); + break; + } + case "placesCmd_showInFolder": + this.showInFolder(this._view.selectedNode.bookmarkGuid); + break; + } + }, + + onEvent: function PC_onEvent(eventName) {}, + + /** + * Determine whether or not the selection can be removed, either by the + * delete or cut operations based on whether or not any of its contents + * are non-removable. We don't need to worry about recursion here since it + * is a policy decision that a removable item not be placed inside a non- + * removable item. + * + * @returns {boolean} true if all nodes in the selection can be removed, + * false otherwise. + */ + _hasRemovableSelection() { + var ranges = this._view.removableSelectionRanges; + if (!ranges.length) { + return false; + } + + var root = this._view.result.root; + + for (var j = 0; j < ranges.length; j++) { + var nodes = ranges[j]; + for (var i = 0; i < nodes.length; ++i) { + // Disallow removing the view's root node + if (nodes[i] == root) { + return false; + } + + if (!PlacesUIUtils.canUserRemove(nodes[i])) { + return false; + } + } + } + + return true; + }, + + /** + * This helper can be used to avoid handling repeated remove operations. + * Clear this._lastRemoveOperationFingerprint if another operation happens. + * + * @returns {boolean} whether the removal is the same as the last one. + */ + _isRepeatedRemoveOperation() { + let lastRemoveOperationFingerprint = this._lastRemoveOperationFingerprint; + this._lastRemoveOperationFingerprint = PlacesUtils.sha256( + this._view.selectedNodes.map(n => n.guid ?? n.uri).join() + ); + return ( + lastRemoveOperationFingerprint == this._lastRemoveOperationFingerprint + ); + }, + + /** + * Gathers information about the selected nodes according to the following + * rules: + * "link" node is a URI + * "bookmark" node is a bookmark + * "tagChild" node is a child of a tag + * "folder" node is a folder + * "query" node is a query + * "separator" node is a separator line + * "host" node is a host + * + * @returns {Array} an array of objects corresponding the selected nodes. Each + * object has each of the properties above set if its corresponding + * node matches the rule. In addition, the annotations names for each + * node are set on its corresponding object as properties. + * Notes: + * 1) This can be slow, so don't call it anywhere performance critical! + */ + _buildSelectionMetadata() { + return this._view.selectedNodes.map(n => this._selectionMetadataForNode(n)); + }, + + _selectionMetadataForNode(node) { + let nodeData = {}; + // We don't use the nodeIs* methods here to avoid going through the type + // property way too often + switch (node.type) { + case Ci.nsINavHistoryResultNode.RESULT_TYPE_QUERY: + nodeData.query = true; + if (node.parent) { + switch (PlacesUtils.asQuery(node.parent).queryOptions.resultType) { + case Ci.nsINavHistoryQueryOptions.RESULTS_AS_SITE_QUERY: + nodeData.query_host = true; + break; + case Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_SITE_QUERY: + case Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_QUERY: + nodeData.query_day = true; + break; + case Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAGS_ROOT: + nodeData.query_tag = true; + } + } + break; + case Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER: + case Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT: + nodeData.folder = true; + break; + case Ci.nsINavHistoryResultNode.RESULT_TYPE_SEPARATOR: + nodeData.separator = true; + break; + case Ci.nsINavHistoryResultNode.RESULT_TYPE_URI: + nodeData.link = true; + if (PlacesUtils.nodeIsBookmark(node)) { + nodeData.link_bookmark = true; + var parentNode = node.parent; + if (parentNode && PlacesUtils.nodeIsTagQuery(parentNode)) { + nodeData.link_bookmark_tag = true; + } + } + break; + } + return nodeData; + }, + + /** + * Determines if a context-menu item should be shown + * + * @param {object} aMenuItem + * the context menu item + * @param {object} aMetaData + * meta data about the selection + * @returns {boolean} true if the conditions (see buildContextMenu) are satisfied + * and the item can be displayed, false otherwise. + */ + _shouldShowMenuItem(aMenuItem, aMetaData) { + if ( + aMenuItem.hasAttribute("hide-if-private-browsing") && + !PrivateBrowsingUtils.enabled + ) { + return false; + } + + if ( + aMenuItem.hasAttribute("hide-if-usercontext-disabled") && + !Services.prefs.getBoolPref("privacy.userContext.enabled", false) + ) { + return false; + } + + let selectiontype = + aMenuItem.getAttribute("selection-type") || "single|multiple"; + + var selectionTypes = selectiontype.split("|"); + if (selectionTypes.includes("any")) { + return true; + } + var count = aMetaData.length; + if (count > 1 && !selectionTypes.includes("multiple")) { + return false; + } + if (count == 1 && !selectionTypes.includes("single")) { + return false; + } + // If there is no selection and selectionType doesn't include `none` + // hide the item, otherwise try to use the root node to extract valid + // metadata to compare against. + if (count == 0) { + if (!selectionTypes.includes("none")) { + return false; + } + aMetaData = [this._selectionMetadataForNode(this._view.result.root)]; + } + + let attr = aMenuItem.getAttribute("hide-if-node-type"); + if (attr) { + let rules = attr.split("|"); + if (aMetaData.some(d => rules.some(r => r in d))) { + return false; + } + } + + attr = aMenuItem.getAttribute("hide-if-node-type-is-only"); + if (attr) { + let rules = attr.split("|"); + if (rules.some(r => aMetaData.every(d => r in d))) { + return false; + } + } + + attr = aMenuItem.getAttribute("node-type"); + if (!attr) { + return true; + } + + let anyMatched = false; + let rules = attr.split("|"); + for (let metaData of aMetaData) { + if (rules.some(r => r in metaData)) { + anyMatched = true; + } else { + return false; + } + } + return anyMatched; + }, + + /** + * Uses meta-data rules set as attributes on the menuitems, representing the + * current selection in the view (see `_buildSelectionMetadata`) and sets the + * visibility state for each menuitem according to the following rules: + * 1) The visibility state is unchanged if none of the attributes are set. + * 2) Attributes should not be set on menuseparators. + * 3) The boolean `ignore-item` attribute may be set when this code should + * not handle that menuitem. + * 4) The `selection-type` attribute may be set to: + * - `single` if it should be visible only when there is a single node + * selected + * - `multiple` if it should be visible only when multiple nodes are + * selected + * - `none` if it should be visible when there are no selected nodes + * - `any` if it should be visible for any kind of selection + * - a `|` separated combination of the above. + * 5) The `node-type` attribute may be set to values representing the + * type of the node triggering the context menu. The menuitem will be + * visible when one of the rules (separated by `|`) matches. + * In case of multiple selection, the menuitem is visible only if all of + * the selected nodes match one of the rule. + * 6) The `hide-if-node-type` accepts the same rules as `node-type`, but + * hides the menuitem if the nodes match at least one of the rules. + * It takes priority over `nodetype`. + * 7) The `hide-if-node-type-is-only` accepts the same rules as `node-type`, but + * hides the menuitem if any of the rules match all of the nodes. + * 8) The boolean `hide-if-no-insertion-point` attribute may be set to hide a + * menuitem when there's no insertion point. An insertion point represents + * a point in the view where a new item can be inserted. + * 9) The boolean `hide-if-private-browsing` attribute may be set to hide a + * menuitem in private browsing mode + * 10) The boolean `hide-if-single-click-opens` attribute may be set to hide a + * menuitem in views opening entries with a single click. + * + * @param {object} aPopup + * The menupopup to build children into. + * @returns {boolean} true if at least one item is visible, false otherwise. + */ + buildContextMenu(aPopup) { + var metadata = this._buildSelectionMetadata(); + var ip = this._view.insertionPoint; + var noIp = !ip || ip.isTag; + + var separator = null; + var visibleItemsBeforeSep = false; + var usableItemCount = 0; + for (var i = 0; i < aPopup.children.length; ++i) { + var item = aPopup.children[i]; + if (item.getAttribute("ignore-item") == "true") { + continue; + } + if (item.localName != "menuseparator") { + // We allow pasting into tag containers, so special case that. + let hideIfNoIP = + item.getAttribute("hide-if-no-insertion-point") == "true" && + noIp && + !(ip && ip.isTag && item.id == "placesContext_paste"); + let hideIfPrivate = + item.getAttribute("hide-if-private-browsing") == "true" && + PrivateBrowsingUtils.isWindowPrivate(window); + // Hide `Open` if the primary action on click is opening. + let hideIfSingleClickOpens = + item.getAttribute("hide-if-single-click-opens") == "true" && + !PlacesUIUtils.loadBookmarksInBackground && + !PlacesUIUtils.loadBookmarksInTabs && + this._view.singleClickOpens; + let hideIfNotSearch = + item.getAttribute("hide-if-not-search") == "true" && + (!this._view.selectedNode || + !this._view.selectedNode.parent || + !PlacesUtils.nodeIsQuery(this._view.selectedNode.parent)); + + let shouldHideItem = + hideIfNoIP || + hideIfPrivate || + hideIfSingleClickOpens || + hideIfNotSearch || + !this._shouldShowMenuItem(item, metadata); + item.hidden = shouldHideItem; + item.disabled = + shouldHideItem || item.getAttribute("start-disabled") == "true"; + + if (!item.hidden) { + visibleItemsBeforeSep = true; + usableItemCount++; + + // Show the separator above the menu-item if any + if (separator) { + separator.hidden = false; + separator = null; + } + } + } else { + // menuseparator + // Initially hide it. It will be unhidden if there will be at least one + // visible menu-item above and below it. + item.hidden = true; + + // We won't show the separator at all if no items are visible above it + if (visibleItemsBeforeSep) { + separator = item; + } + + // New separator, count again: + visibleItemsBeforeSep = false; + } + + if (item.id === "placesContext_deleteBookmark") { + document.l10n.setAttributes(item, "places-delete-bookmark", { + count: metadata.length, + }); + } + if (item.id === "placesContext_deleteFolder") { + document.l10n.setAttributes(item, "places-delete-folder", { + count: metadata.length, + }); + } + } + + // Set Open Folder/Links In Tabs or Open Bookmark item's enabled state if they're visible + if (usableItemCount > 0) { + let openContainerInTabsItem = document.getElementById( + "placesContext_openContainer:tabs" + ); + let openBookmarksItem = document.getElementById( + "placesContext_openBookmarkContainer:tabs" + ); + for (let menuItem of [openContainerInTabsItem, openBookmarksItem]) { + if (!menuItem.hidden) { + var containerToUse = + this._view.selectedNode || this._view.result.root; + if (PlacesUtils.nodeIsContainer(containerToUse)) { + if (!PlacesUtils.hasChildURIs(containerToUse)) { + menuItem.disabled = true; + // Ensure that we don't display the menu if nothing is enabled: + usableItemCount--; + } + } + } + } + } + + const deleteHistoryItem = document.getElementById( + "placesContext_delete_history" + ); + document.l10n.setAttributes(deleteHistoryItem, "places-delete-page", { + count: metadata.length, + }); + + const createBookmarkItem = document.getElementById( + "placesContext_createBookmark" + ); + document.l10n.setAttributes(createBookmarkItem, "places-create-bookmark", { + count: metadata.length, + }); + + return usableItemCount > 0; + }, + + /** + * Select all links in the current view. + */ + selectAll: function PC_selectAll() { + this._view.selectAll(); + }, + + /** + * Opens the bookmark properties for the selected URI Node. + */ + showBookmarkPropertiesForSelection() { + let node = this._view.selectedNode; + if (!node) { + return; + } + + PlacesUIUtils.showBookmarkDialog( + { action: "edit", node, hiddenRows: ["folderPicker"] }, + window.top + ); + }, + + /** + * Opens the links in the selected folder, or the selected links in new tabs. + * + * @param {object} aEvent + * The associated event. + */ + openSelectionInTabs: function PC_openLinksInTabs(aEvent) { + var node = this._view.selectedNode; + var nodes = this._view.selectedNodes; + // In the case of no selection, open the root node: + if (!node && !nodes.length) { + node = this._view.result.root; + } + PlacesUIUtils.openMultipleLinksInTabs( + node ? node : nodes, + aEvent, + this._view + ); + }, + + /** + * Shows the Add Bookmark UI for the current insertion point. + * + * @param {string} aType + * the type of the new item (bookmark/folder) + */ + async newItem(aType) { + let ip = this._view.insertionPoint; + if (!ip) { + throw Components.Exception("", Cr.NS_ERROR_NOT_AVAILABLE); + } + + let bookmarkGuid = await PlacesUIUtils.showBookmarkDialog( + { + action: "add", + type: aType, + defaultInsertionPoint: ip, + hiddenRows: ["folderPicker"], + }, + window.top + ); + if (bookmarkGuid) { + this._view.selectItems([bookmarkGuid], false); + } + }, + + /** + * Create a new Bookmark separator somewhere. + */ + async newSeparator() { + var ip = this._view.insertionPoint; + if (!ip) { + throw Components.Exception("", Cr.NS_ERROR_NOT_AVAILABLE); + } + + let index = await ip.getIndex(); + let txn = PlacesTransactions.NewSeparator({ parentGuid: ip.guid, index }); + let guid = await txn.transact(); + // Select the new item. + this._view.selectItems([guid], false); + }, + + /** + * Sort the selected folder by name + */ + async sortFolderByName() { + let guid = PlacesUtils.getConcreteItemGuid(this._view.selectedNode); + await PlacesTransactions.SortByName(guid).transact(); + }, + + /** + * Walk the list of folders we're removing in this delete operation, and + * see if the selected node specified is already implicitly being removed + * because it is a child of that folder. + * + * @param {object} node + * Node to check for containment. + * @param {Array} pastFolders + * List of folders the calling function has already traversed + * @returns {boolean} true if the node should be skipped, false otherwise. + */ + _shouldSkipNode: function PC_shouldSkipNode(node, pastFolders) { + /** + * Determines if a node is contained by another node within a resultset. + * + * @param {object} parent + * The parent container to check for containment in + * @returns {boolean} true if node is a member of parent's children, false otherwise. + */ + function isNodeContainedBy(parent) { + var cursor = node.parent; + while (cursor) { + if (cursor == parent) { + return true; + } + cursor = cursor.parent; + } + return false; + } + + for (var j = 0; j < pastFolders.length; ++j) { + if (isNodeContainedBy(pastFolders[j])) { + return true; + } + } + return false; + }, + + /** + * Creates a set of transactions for the removal of a range of items. + * A range is an array of adjacent nodes in a view. + * + * @param {Array} range + * An array of nodes to remove. Should all be adjacent. + * @param {Array} transactions + * An array of transactions (returned) + * @param {Array} [removedFolders] + * An array of folder nodes that have already been removed. + * @returns {number} The total number of items affected. + */ + async _removeRange(range, transactions, removedFolders) { + if (!(transactions instanceof Array)) { + throw new Error("Must pass a transactions array"); + } + if (!removedFolders) { + removedFolders = []; + } + + let bmGuidsToRemove = []; + let totalItems = 0; + + for (var i = 0; i < range.length; ++i) { + var node = range[i]; + if (this._shouldSkipNode(node, removedFolders)) { + continue; + } + + totalItems++; + + if (PlacesUtils.nodeIsTagQuery(node.parent)) { + // This is a uri node inside a tag container. It needs a special + // untag transaction. + let tag = node.parent.title || ""; + if (!tag) { + // The parent may be the root node, that doesn't have a title. + tag = node.parent.query.tags[0]; + } + transactions.push(PlacesTransactions.Untag({ urls: [node.uri], tag })); + } else if ( + PlacesUtils.nodeIsTagQuery(node) && + node.parent && + PlacesUtils.nodeIsQuery(node.parent) && + PlacesUtils.asQuery(node.parent).queryOptions.resultType == + Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAGS_ROOT + ) { + // This is a tag container. + // Untag all URIs tagged with this tag only if the tag container is + // child of the "Tags" query in the library, in all other places we + // must only remove the query node. + let tag = node.title; + let urls = new Set(); + await PlacesUtils.bookmarks.fetch({ tags: [tag] }, b => + urls.add(b.url) + ); + transactions.push( + PlacesTransactions.Untag({ tag, urls: Array.from(urls) }) + ); + } else if ( + PlacesUtils.nodeIsURI(node) && + PlacesUtils.nodeIsQuery(node.parent) && + PlacesUtils.asQuery(node.parent).queryOptions.queryType == + Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY + ) { + // This is a uri node inside an history query. + await PlacesUtils.history.remove(node.uri).catch(console.error); + // History deletes are not undoable, so we don't have a transaction. + } else if ( + node.itemId == -1 && + PlacesUtils.nodeIsQuery(node) && + PlacesUtils.asQuery(node).queryOptions.queryType == + Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY + ) { + // This is a dynamically generated history query, like queries + // grouped by site, time or both. Dynamically generated queries don't + // have an itemId even if they are descendants of a bookmark. + await this._removeHistoryContainer(node).catch(console.error); + // History deletes are not undoable, so we don't have a transaction. + } else { + // This is a common bookmark item. + if (PlacesUtils.nodeIsFolder(node)) { + // If this is a folder we add it to our array of folders, used + // to skip nodes that are children of an already removed folder. + removedFolders.push(node); + } + bmGuidsToRemove.push(node.bookmarkGuid); + } + } + if (bmGuidsToRemove.length) { + transactions.push(PlacesTransactions.Remove({ guids: bmGuidsToRemove })); + } + return totalItems; + }, + + async _removeRowsFromBookmarks() { + let ranges = this._view.removableSelectionRanges; + let transactions = []; + let removedFolders = []; + let totalItems = 0; + + for (let range of ranges) { + totalItems += await this._removeRange( + range, + transactions, + removedFolders + ); + } + + if (transactions.length) { + await PlacesUIUtils.batchUpdatesForNode( + this._view.result, + totalItems, + async () => { + await PlacesTransactions.batch( + transactions, + "PlacesController::removeRowsFromBookmarks" + ); + } + ); + } + }, + + /** + * Removes the set of selected ranges from history, asynchronously. History + * deletes are not undoable. + */ + async _removeRowsFromHistory() { + let nodes = this._view.selectedNodes; + let URIs = new Set(); + for (let i = 0; i < nodes.length; ++i) { + let node = nodes[i]; + if (PlacesUtils.nodeIsURI(node)) { + URIs.add(node.uri); + } else if ( + PlacesUtils.nodeIsQuery(node) && + PlacesUtils.asQuery(node).queryOptions.queryType == + Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY + ) { + await this._removeHistoryContainer(node).catch(console.error); + } + } + + if (URIs.size) { + await PlacesUIUtils.batchUpdatesForNode( + this._view.result, + URIs.size, + async () => { + await PlacesUtils.history.remove([...URIs]); + } + ); + } + }, + + /** + * Removes history visits for an history container node. History deletes are + * not undoable. + * + * @param {object} aContainerNode + * The container node to remove. + */ + async _removeHistoryContainer(aContainerNode) { + if (PlacesUtils.nodeIsHost(aContainerNode)) { + // This is a site container. + // Check if it's the container for local files (don't be fooled by the + // bogus string name, this is "(local files)"). + let host = + "." + + (aContainerNode.title == PlacesUtils.getString("localhost") + ? "" + : aContainerNode.title); + // Will update faster if all children hidden before removing + aContainerNode.containerOpen = false; + await PlacesUtils.history.removeByFilter({ host }); + } else if (PlacesUtils.nodeIsDay(aContainerNode)) { + // This is a day container. + let query = aContainerNode.query; + let beginTime = query.beginTime; + let endTime = query.endTime; + if (!query || !beginTime || !endTime) { + throw new Error("A valid date container query should exist!"); + } + // Will update faster if all children hidden before removing + aContainerNode.containerOpen = false; + // We want to exclude beginTime from the removal because + // removePagesByTimeframe includes both extremes, while date containers + // exclude the lower extreme. So, if we would not exclude it, we would + // end up removing more history than requested. + await PlacesUtils.history.removeByFilter({ + beginDate: PlacesUtils.toDate(beginTime + 1000), + endDate: PlacesUtils.toDate(endTime), + }); + } + }, + + /** + * Removes the selection + */ + async remove() { + if (!this._hasRemovableSelection()) { + return; + } + + // Sometimes we get repeated remove operation requests, because the user is + // holding down the DEL key. Since removal operations are asynchronous + // that would cause duplicated remove transactions that perform badly, + // increase memory usage (duplicate data), and cause failures (trying to + // act on already removed nodes). + if (this._isRepeatedRemoveOperation()) { + return; + } + + var root = this._view.result.root; + + if (PlacesUtils.nodeIsFolder(root)) { + await this._removeRowsFromBookmarks(); + } else if (PlacesUtils.nodeIsQuery(root)) { + var queryType = PlacesUtils.asQuery(root).queryOptions.queryType; + if (queryType == Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS) { + await this._removeRowsFromBookmarks(); + } else if (queryType == Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY) { + await this._removeRowsFromHistory(); + } else { + throw new Error("Unknown query type"); + } + } else { + throw new Error("unexpected root"); + } + }, + + /** + * Fills a DataTransfer object with the content of the selection that can be + * dropped elsewhere. + * + * @param {object} aEvent + * The dragstart event. + */ + setDataTransfer: function PC_setDataTransfer(aEvent) { + let dt = aEvent.dataTransfer; + + let result = this._view.result; + let didSuppressNotifications = result.suppressNotifications; + if (!didSuppressNotifications) { + result.suppressNotifications = true; + } + + function addData(type, index) { + let wrapNode = PlacesUtils.wrapNode(node, type); + dt.mozSetDataAt(type, wrapNode, index); + } + + function addURIData(index) { + addData(PlacesUtils.TYPE_X_MOZ_URL, index); + addData(PlacesUtils.TYPE_PLAINTEXT, index); + addData(PlacesUtils.TYPE_HTML, index); + } + + try { + let nodes = this._view.draggableSelection; + for (let i = 0; i < nodes.length; ++i) { + var node = nodes[i]; + + // This order is _important_! It controls how this and other + // applications select data to be inserted based on type. + addData(PlacesUtils.TYPE_X_MOZ_PLACE, i); + if (node.uri) { + addURIData(i); + } + } + } finally { + if (!didSuppressNotifications) { + result.suppressNotifications = false; + } + } + }, + + get clipboardAction() { + let action = {}; + let actionOwner; + try { + let xferable = Cc["@mozilla.org/widget/transferable;1"].createInstance( + Ci.nsITransferable + ); + xferable.init(null); + xferable.addDataFlavor(PlacesUtils.TYPE_X_MOZ_PLACE_ACTION); + Services.clipboard.getData(xferable, Ci.nsIClipboard.kGlobalClipboard); + xferable.getTransferData(PlacesUtils.TYPE_X_MOZ_PLACE_ACTION, action); + [action, actionOwner] = action.value + .QueryInterface(Ci.nsISupportsString) + .data.split(","); + } catch (ex) { + // Paste from external sources don't have any associated action, just + // fallback to a copy action. + return "copy"; + } + // For cuts also check who inited the action, since cuts across different + // instances should instead be handled as copies (The sources are not + // available for this instance). + if (action == "cut" && actionOwner != this.profileName) { + action = "copy"; + } + + return action; + }, + + _releaseClipboardOwnership: function PC__releaseClipboardOwnership() { + if (this.cutNodes.length) { + // This clears the logical clipboard, doesn't remove data. + Services.clipboard.emptyClipboard(Ci.nsIClipboard.kGlobalClipboard); + } + }, + + _clearClipboard: function PC__clearClipboard() { + let xferable = Cc["@mozilla.org/widget/transferable;1"].createInstance( + Ci.nsITransferable + ); + xferable.init(null); + // Empty transferables may cause crashes, so just add an unknown type. + const TYPE = "text/x-moz-place-empty"; + xferable.addDataFlavor(TYPE); + xferable.setTransferData(TYPE, PlacesUtils.toISupportsString("")); + Services.clipboard.setData( + xferable, + null, + Ci.nsIClipboard.kGlobalClipboard + ); + }, + + _populateClipboard: function PC__populateClipboard(aNodes, aAction) { + // This order is _important_! It controls how this and other applications + // select data to be inserted based on type. + let contents = [ + { type: PlacesUtils.TYPE_X_MOZ_PLACE, entries: [] }, + { type: PlacesUtils.TYPE_X_MOZ_URL, entries: [] }, + { type: PlacesUtils.TYPE_HTML, entries: [] }, + { type: PlacesUtils.TYPE_PLAINTEXT, entries: [] }, + ]; + + // Avoid handling descendants of a copied node, the transactions take care + // of them automatically. + let copiedFolders = []; + aNodes.forEach(function (node) { + if (this._shouldSkipNode(node, copiedFolders)) { + return; + } + if (PlacesUtils.nodeIsFolder(node)) { + copiedFolders.push(node); + } + + contents.forEach(function (content) { + content.entries.push(PlacesUtils.wrapNode(node, content.type)); + }); + }, this); + + function addData(type, data) { + xferable.addDataFlavor(type); + xferable.setTransferData(type, PlacesUtils.toISupportsString(data)); + } + + let xferable = Cc["@mozilla.org/widget/transferable;1"].createInstance( + Ci.nsITransferable + ); + xferable.init(null); + let hasData = false; + // This order matters here! It controls how this and other applications + // select data to be inserted based on type. + contents.forEach(function (content) { + if (content.entries.length) { + hasData = true; + let glue = + content.type == PlacesUtils.TYPE_X_MOZ_PLACE ? "," : PlacesUtils.endl; + addData(content.type, content.entries.join(glue)); + } + }); + + // Track the exected action in the xferable. This must be the last flavor + // since it's the least preferred one. + // Enqueue a unique instance identifier to distinguish operations across + // concurrent instances of the application. + addData( + PlacesUtils.TYPE_X_MOZ_PLACE_ACTION, + aAction + "," + this.profileName + ); + + if (hasData) { + Services.clipboard.setData( + xferable, + aAction == "cut" ? this : null, + Ci.nsIClipboard.kGlobalClipboard + ); + } + }, + + _cutNodes: [], + get cutNodes() { + return this._cutNodes; + }, + set cutNodes(aNodes) { + let self = this; + function updateCutNodes(aValue) { + self._cutNodes.forEach(function (aNode) { + self._view.toggleCutNode(aNode, aValue); + }); + } + + updateCutNodes(false); + this._cutNodes = aNodes; + updateCutNodes(true); + }, + + /** + * Copy Bookmarks and Folders to the clipboard + */ + copy: function PC_copy() { + let result = this._view.result; + let didSuppressNotifications = result.suppressNotifications; + if (!didSuppressNotifications) { + result.suppressNotifications = true; + } + try { + this._populateClipboard(this._view.selectedNodes, "copy"); + } finally { + if (!didSuppressNotifications) { + result.suppressNotifications = false; + } + } + }, + + /** + * Cut Bookmarks and Folders to the clipboard + */ + cut: function PC_cut() { + let result = this._view.result; + let didSuppressNotifications = result.suppressNotifications; + if (!didSuppressNotifications) { + result.suppressNotifications = true; + } + try { + this._populateClipboard(this._view.selectedNodes, "cut"); + this.cutNodes = this._view.selectedNodes; + } finally { + if (!didSuppressNotifications) { + result.suppressNotifications = false; + } + } + }, + + /** + * Paste Bookmarks and Folders from the clipboard + */ + async paste() { + // No reason to proceed if there isn't a valid insertion point. + let ip = this._view.insertionPoint; + if (!ip) { + throw Components.Exception("", Cr.NS_ERROR_NOT_AVAILABLE); + } + + let action = this.clipboardAction; + + let xferable = Cc["@mozilla.org/widget/transferable;1"].createInstance( + Ci.nsITransferable + ); + xferable.init(null); + // This order matters here! It controls the preferred flavors for this + // paste operation. + [ + PlacesUtils.TYPE_X_MOZ_PLACE, + PlacesUtils.TYPE_X_MOZ_URL, + PlacesUtils.TYPE_PLAINTEXT, + ].forEach(type => xferable.addDataFlavor(type)); + + Services.clipboard.getData(xferable, Ci.nsIClipboard.kGlobalClipboard); + + // Now get the clipboard contents, in the best available flavor. + let data = {}, + type = {}, + items = []; + try { + xferable.getAnyTransferData(type, data); + data = data.value.QueryInterface(Ci.nsISupportsString).data; + type = type.value; + items = PlacesUtils.unwrapNodes(data, type); + } catch (ex) { + // No supported data exists or nodes unwrap failed, just bail out. + return; + } + + let doCopy = action == "copy"; + let itemsToSelect = await PlacesUIUtils.handleTransferItems( + items, + ip, + doCopy, + this._view + ); + + // Cut/past operations are not repeatable, so clear the clipboard. + if (action == "cut") { + this._clearClipboard(); + } + + if (itemsToSelect.length) { + this._view.selectItems(itemsToSelect, false); + } + }, + + /** + * Checks if we can insert into a container. + * + * @param {object} container + * The container were we are want to drop + * @returns {boolean} + */ + disallowInsertion(container) { + if (!container) { + throw new Error("empty container"); + } + // Allow dropping into Tag containers and editable folders. + return ( + !PlacesUtils.nodeIsTagQuery(container) && + (!PlacesUtils.nodeIsFolder(container) || + PlacesUIUtils.isFolderReadOnly(container)) + ); + }, + + /** + * Determines if a node can be moved. + * + * @param {object} node + * A nsINavHistoryResultNode node. + * @returns {boolean} True if the node can be moved, false otherwise. + */ + canMoveNode(node) { + // Only bookmark items are movable. + if (node.itemId == -1) { + return false; + } + + // Once tags and bookmarked are divorced, the tag-query check should be + // removed. + let parentNode = node.parent; + if (!parentNode) { + return false; + } + + // Once tags and bookmarked are divorced, the tag-query check should be + // removed. + if (PlacesUtils.nodeIsTagQuery(parentNode)) { + return false; + } + + return ( + (PlacesUtils.nodeIsFolder(parentNode) && + !PlacesUIUtils.isFolderReadOnly(parentNode)) || + PlacesUtils.nodeIsQuery(parentNode) + ); + }, + async forgetAboutThisSite() { + let host; + if (PlacesUtils.nodeIsHost(this._view.selectedNode)) { + host = this._view.selectedNode.query.domain; + } else { + host = Services.io.newURI(this._view.selectedNode.uri).host; + } + let baseDomain; + try { + baseDomain = Services.eTLD.getBaseDomainFromHost(host); + } catch (e) { + // If there is no baseDomain we fall back to host + } + const [title, body, forget] = await document.l10n.formatValues([ + { id: "places-forget-about-this-site-confirmation-title" }, + { + id: "places-forget-about-this-site-confirmation-msg", + args: { hostOrBaseDomain: baseDomain ?? host }, + }, + { id: "places-forget-about-this-site-forget" }, + ]); + + const flags = + Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_0 + + Services.prompt.BUTTON_TITLE_CANCEL * Services.prompt.BUTTON_POS_1 + + Services.prompt.BUTTON_POS_1_DEFAULT; + + let bag = await Services.prompt.asyncConfirmEx( + window.browsingContext, + Services.prompt.MODAL_TYPE_INTERNAL_WINDOW, + title, + body, + flags, + forget, + null, + null, + null, + false + ); + if (bag.getProperty("buttonNumClicked") !== 0) { + return; + } + + if (this.forgetSiteClearByBaseDomain) { + await this.ForgetAboutSite.removeDataFromBaseDomain(host); + } else { + await this.ForgetAboutSite.removeDataFromDomain(host); + } + }, + + showInFolder(aBookmarkGuid) { + // Open containing folder in left pane/sidebar bookmark tree + let documentUrl = document.documentURI.toLowerCase(); + if (documentUrl.endsWith("browser.xhtml")) { + // We're in a menu or a panel. + window.SidebarUI._show("viewBookmarksSidebar").then(() => { + let theSidebar = document.getElementById("sidebar"); + theSidebar.contentDocument + .getElementById("bookmarks-view") + .selectItems([aBookmarkGuid]); + }); + } else if (documentUrl.includes("sidebar")) { + // We're in the sidebar - clear the search box first + let searchBox = document.getElementById("search-box"); + searchBox.value = ""; + searchBox.doCommand(); + + // And go to the node + this._view.selectItems([aBookmarkGuid], true); + } else { + // We're in the bookmark library/manager + PlacesUtils.bookmarks + .fetch(aBookmarkGuid, null, { includePath: true }) + .then(b => { + let containers = b.path.map(obj => { + return obj.guid; + }); + // selectLeftPane looks for literal "AllBookmarks" as a "built-in" + containers.splice(0, 0, "AllBookmarks"); + PlacesOrganizer.selectLeftPaneContainerByHierarchy(containers); + this._view.selectItems([aBookmarkGuid], false); + }); + } + }, +}; + +/** + * Handles drag and drop operations for views. Note that this is view agnostic! + * You should not use PlacesController._view within these methods, since + * the view that the item(s) have been dropped on was not necessarily active. + * Drop functions are passed the view that is being dropped on. + */ +var PlacesControllerDragHelper = { + /** + * For views using DOM nodes like toolbars, menus and panels, this is the DOM + * element currently being dragged over. For other views not handling DOM + * nodes, like trees, it is a Places result node instead. + */ + currentDropTarget: null, + + /** + * Determines if the mouse is currently being dragged over a child node of + * this menu. This is necessary so that the menu doesn't close while the + * mouse is dragging over one of its submenus + * + * @param {object} node + * The container node + * @returns {boolean} true if the user is dragging over a node within the hierarchy of + * the container, false otherwise. + */ + draggingOverChildNode: function PCDH_draggingOverChildNode(node) { + let currentNode = this.currentDropTarget; + while (currentNode) { + if (currentNode == node) { + return true; + } + currentNode = currentNode.parentNode; + } + return false; + }, + + /** + * @returns {object|null} The current active drag session. Returns null if there is none. + */ + getSession: function PCDH__getSession() { + return this.dragService.getCurrentSession(); + }, + + /** + * Extract the most relevant flavor from a list of flavors. + * + * @param {DOMStringList} flavors The flavors list. + * @returns {string} The most relevant flavor, or undefined. + */ + getMostRelevantFlavor(flavors) { + // The DnD API returns a DOMStringList, but tests may pass an Array. + flavors = Array.from(flavors); + return PlacesUIUtils.SUPPORTED_FLAVORS.find(f => flavors.includes(f)); + }, + + /** + * Determines whether or not the data currently being dragged can be dropped + * on a places view. + * + * @param {object} ip + * The insertion point where the items should be dropped. + * @param {object} dt + * The data transfer object. + * @returns {boolean} + */ + canDrop: function PCDH_canDrop(ip, dt) { + let dropCount = dt.mozItemCount; + + // Check every dragged item. + for (let i = 0; i < dropCount; i++) { + let flavor = this.getMostRelevantFlavor(dt.mozTypesAt(i)); + if (!flavor) { + return false; + } + + // Urls can be dropped on any insertionpoint. + // XXXmano: remember that this method is called for each dragover event! + // Thus we shouldn't use unwrapNodes here at all if possible. + // I think it would be OK to accept bogus data here (e.g. text which was + // somehow wrapped as TAB_DROP_TYPE, this is not in our control, and + // will just case the actual drop to be a no-op), and only rule out valid + // expected cases, which are either unsupported flavors, or items which + // cannot be dropped in the current insertionpoint. The last case will + // likely force us to use unwrapNodes for the private data types of + // places. + if (flavor == TAB_DROP_TYPE) { + continue; + } + + let data = dt.mozGetDataAt(flavor, i); + let nodes; + try { + nodes = PlacesUtils.unwrapNodes(data, flavor); + } catch (e) { + return false; + } + + for (let dragged of nodes) { + // Only bookmarks and urls can be dropped into tag containers. + if ( + ip.isTag && + dragged.type != PlacesUtils.TYPE_X_MOZ_URL && + (dragged.type != PlacesUtils.TYPE_X_MOZ_PLACE || + (dragged.uri && dragged.uri.startsWith("place:"))) + ) { + return false; + } + + // Disallow dropping of a folder on itself or any of its descendants. + // This check is done to show an appropriate drop indicator, a stricter + // check is done later by the bookmarks API. + if ( + dragged.type == PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER || + (dragged.uri && dragged.uri.startsWith("place:")) + ) { + let dragOverPlacesNode = this.currentDropTarget; + if (!(dragOverPlacesNode instanceof Ci.nsINavHistoryResultNode)) { + // If it's a DOM node, it should have a _placesNode expando, or it + // may be a static element in a places container, like the [empty] + // menuitem. + dragOverPlacesNode = + dragOverPlacesNode._placesNode ?? + dragOverPlacesNode.parentNode?._placesNode; + } + + // If we couldn't get a target Places result node then we can't check + // whether the drag is allowed, just let it go through. + if (dragOverPlacesNode) { + let guid = dragged.concreteGuid ?? dragged.itemGuid; + // Dragging over itself. + if (PlacesUtils.getConcreteItemGuid(dragOverPlacesNode) == guid) { + return false; + } + // Dragging over a descendant. + for (let ancestor of PlacesUtils.nodeAncestors( + dragOverPlacesNode + )) { + if (PlacesUtils.getConcreteItemGuid(ancestor) == guid) { + return false; + } + } + } + } + + // Disallow the dropping of multiple bookmarks if they include + // a javascript: bookmarklet + if ( + !flavor.startsWith("text/x-moz-place") && + (nodes.length > 1 || dropCount > 1) && + nodes.some(n => n.uri?.startsWith("javascript:")) + ) { + return false; + } + } + } + return true; + }, + + /** + * Handles the drop of one or more items onto a view. + * + * @param {object} insertionPoint The insertion point where the items should + * be dropped. + * @param {object} dt The dataTransfer information for the drop. + * @param {object} [view] The view or the tree element. This allows + * batching to take place. + */ + async onDrop(insertionPoint, dt, view) { + let doCopy = ["copy", "link"].includes(dt.dropEffect); + + let dropCount = dt.mozItemCount; + + // Following flavors may contain duplicated data. + let duplicable = new Map(); + duplicable.set(PlacesUtils.TYPE_PLAINTEXT, new Set()); + duplicable.set(PlacesUtils.TYPE_X_MOZ_URL, new Set()); + + // Collect all data from the DataTransfer before processing it, as the + // DataTransfer is only valid during the synchronous handling of the `drop` + // event handler callback. + let nodes = []; + let externalDrag = false; + for (let i = 0; i < dropCount; ++i) { + let flavor = this.getMostRelevantFlavor(dt.mozTypesAt(i)); + if (!flavor) { + return; + } + + let data = dt.mozGetDataAt(flavor, i); + if (duplicable.has(flavor)) { + let handled = duplicable.get(flavor); + if (handled.has(data)) { + continue; + } + handled.add(data); + } + + // Check that the drag/drop is not internal + if (i == 0 && !flavor.startsWith("text/x-moz-place")) { + externalDrag = true; + } + + if (flavor != TAB_DROP_TYPE) { + nodes = [...nodes, ...PlacesUtils.unwrapNodes(data, flavor)]; + } else if ( + XULElement.isInstance(data) && + data.localName == "tab" && + data.ownerGlobal.isChromeWindow + ) { + let uri = data.linkedBrowser.currentURI; + let spec = uri ? uri.spec : "about:blank"; + nodes.push({ + uri: spec, + title: data.label, + type: PlacesUtils.TYPE_X_MOZ_URL, + }); + } else { + throw new Error("bogus data was passed as a tab"); + } + } + + // If a multiple urls are being dropped from the urlbar or an external source, + // and they include javascript url, not bookmark any of them + if ( + externalDrag && + (nodes.length > 1 || dropCount > 1) && + nodes.some(n => n.uri?.startsWith("javascript:")) + ) { + throw new Error("Javascript bookmarklet passed with uris"); + } + + // If a single javascript url is being dropped from the urlbar or an external source, + // show the bookmark dialog as a speedbump protection against malicious cases. + if ( + nodes.length == 1 && + externalDrag && + nodes[0].uri?.startsWith("javascript") + ) { + let uri; + try { + uri = Services.io.newURI(nodes[0].uri); + } catch (ex) { + // Invalid uri, we skip this code and the entry will be discarded later. + } + + if (uri) { + let bookmarkGuid = await PlacesUIUtils.showBookmarkDialog( + { + action: "add", + type: "bookmark", + defaultInsertionPoint: insertionPoint, + hiddenRows: ["folderPicker"], + title: nodes[0].title, + uri, + }, + BrowserWindowTracker.getTopWindow() // `window` may be the Library. + ); + + if (bookmarkGuid && view) { + view.selectItems([bookmarkGuid], false); + } + + return; + } + } + + await PlacesUIUtils.handleTransferItems( + nodes, + insertionPoint, + doCopy, + view + ); + }, +}; + +XPCOMUtils.defineLazyServiceGetter( + PlacesControllerDragHelper, + "dragService", + "@mozilla.org/widget/dragservice;1", + "nsIDragService" +); diff --git a/browser/components/places/content/editBookmark.js b/browser/components/places/content/editBookmark.js new file mode 100644 index 0000000000..66d1a73293 --- /dev/null +++ b/browser/components/places/content/editBookmark.js @@ -0,0 +1,1255 @@ +/* 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/. */ + +/* global MozXULElement */ + +// This is defined in browser.js and only used in the star UI. +/* global setToolbarVisibility */ + +/* import-globals-from controller.js */ + +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + CustomizableUI: "resource:///modules/CustomizableUI.sys.mjs", + PlacesTransactions: "resource://gre/modules/PlacesTransactions.sys.mjs", + PlacesUIUtils: "resource:///modules/PlacesUIUtils.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", +}); + +var gEditItemOverlay = { + // Array of PlacesTransactions accumulated by internal changes. It can be used + // to wait for completion. + transactionPromises: null, + _staticFoldersListBuilt: false, + _didChangeFolder: false, + // Tracks bookmark properties changes in the dialog, allowing external consumers + // to either confirm or discard them. + _bookmarkState: null, + _allTags: null, + _initPanelDeferred: null, + _updateTagsDeferred: null, + _paneInfo: null, + _setPaneInfo(aInitInfo) { + if (!aInitInfo) { + return (this._paneInfo = null); + } + + if ("uris" in aInitInfo && "node" in aInitInfo) { + throw new Error("ambiguous pane info"); + } + if (!("uris" in aInitInfo) && !("node" in aInitInfo)) { + throw new Error("Neither node nor uris set for pane info"); + } + + // We either pass a node or uris. + let node = "node" in aInitInfo ? aInitInfo.node : null; + + // Since there's no true UI for folder shortcuts (they show up just as their target + // folders), when the pane shows for them it's opened in read-only mode, showing the + // properties of the target folder. + let itemGuid = node ? PlacesUtils.getConcreteItemGuid(node) : null; + let isItem = !!itemGuid; + let isFolderShortcut = + isItem && + node.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT; + let isTag = node && PlacesUtils.nodeIsTagQuery(node); + let tag = null; + if (isTag) { + tag = + PlacesUtils.asQuery(node).query.tags.length == 1 + ? node.query.tags[0] + : node.title; + } + let isURI = node && PlacesUtils.nodeIsURI(node); + let uri = isURI || isTag ? Services.io.newURI(node.uri) : null; + let title = node ? node.title : null; + let isBookmark = isItem && isURI; + let bulkTagging = !node; + let uris = bulkTagging ? aInitInfo.uris : null; + let visibleRows = new Set(); + let isParentReadOnly = false; + let postData = aInitInfo.postData; + let parentGuid = null; + + if (node && isItem) { + if (!node.parent) { + throw new Error( + "Cannot use an incomplete node to initialize the edit bookmark panel" + ); + } + let parent = node.parent; + isParentReadOnly = !PlacesUtils.nodeIsFolder(parent); + // Note this may be an empty string, that'd the case for the root node + // of a search, or a virtual root node, like the Library left pane. + parentGuid = parent.bookmarkGuid; + } + + let focusedElement = aInitInfo.focusedElement; + let onPanelReady = aInitInfo.onPanelReady; + + return (this._paneInfo = { + itemGuid, + parentGuid, + isItem, + isURI, + uri, + title, + isBookmark, + isFolderShortcut, + isParentReadOnly, + bulkTagging, + uris, + visibleRows, + postData, + isTag, + focusedElement, + onPanelReady, + tag, + }); + }, + + get initialized() { + return this._paneInfo != null; + }, + + /** + * The concrete bookmark GUID is either the bookmark one or, for folder + * shortcuts, the target one. + * + * @returns {string} GUID of the loaded bookmark, or null if not a bookmark. + */ + get concreteGuid() { + if ( + !this.initialized || + this._paneInfo.isTag || + this._paneInfo.bulkTagging + ) { + return null; + } + return this._paneInfo.itemGuid; + }, + + get uri() { + if (!this.initialized) { + return null; + } + if (this._paneInfo.bulkTagging) { + return this._paneInfo.uris[0]; + } + return this._paneInfo.uri; + }, + + get multiEdit() { + return this.initialized && this._paneInfo.bulkTagging; + }, + + // Check if the pane is initialized to show only read-only fields. + get readOnly() { + // TODO (Bug 1120314): Folder shortcuts are currently read-only due to some + // quirky implementation details (the most important being the "smart" + // semantics of node.title that makes hard to edit the right entry). + // This pane is read-only if: + // * the panel is not initialized + // * the node is a folder shortcut + // * the node is not bookmarked and not a tag container + // * the node is child of a read-only container and is not a bookmarked + // URI nor a tag container + return ( + !this.initialized || + this._paneInfo.isFolderShortcut || + (!this._paneInfo.isItem && !this._paneInfo.isTag) || + (this._paneInfo.isParentReadOnly && + !this._paneInfo.isBookmark && + !this._paneInfo.isTag) + ); + }, + + get didChangeFolder() { + return this._didChangeFolder; + }, + + // the first field which was edited after this panel was initialized for + // a certain item + _firstEditedField: "", + + _initNamePicker() { + if (this._paneInfo.bulkTagging) { + throw new Error("_initNamePicker called unexpectedly"); + } + + // title may by null, which, for us, is the same as an empty string. + this._initTextField( + this._namePicker, + this._paneInfo.title || this._paneInfo.tag || "" + ); + }, + + _initLocationField() { + if (!this._paneInfo.isURI) { + throw new Error("_initLocationField called unexpectedly"); + } + this._initTextField(this._locationField, this._paneInfo.uri.spec); + }, + + async _initKeywordField(newKeyword = "") { + if (!this._paneInfo.isBookmark) { + throw new Error("_initKeywordField called unexpectedly"); + } + + // Reset the field status synchronously now, eventually we'll reinit it + // later if we find an existing keyword. This way we can ensure to be in a + // consistent status when reusing the panel across different bookmarks. + this._keyword = newKeyword; + this._initTextField(this._keywordField, newKeyword); + + if (!newKeyword) { + let entries = []; + await PlacesUtils.keywords.fetch({ url: this._paneInfo.uri.spec }, e => + entries.push(e) + ); + if (entries.length) { + // We show an existing keyword if either POST data was not provided, or + // if the POST data is the same. + let existingKeyword = entries[0].keyword; + let postData = this._paneInfo.postData; + if (postData) { + let sameEntry = entries.find(e => e.postData === postData); + existingKeyword = sameEntry ? sameEntry.keyword : ""; + } + if (existingKeyword) { + this._keyword = existingKeyword; + // Update the text field to the existing keyword. + this._initTextField(this._keywordField, this._keyword); + } + } + } + }, + + async _initAllTags() { + this._allTags = new Map(); + const fetchedTags = await PlacesUtils.bookmarks.fetchTags(); + for (const tag of fetchedTags) { + this._allTags?.set(tag.name.toLowerCase(), tag.name); + } + }, + + /** + * Initialize the panel. + * + * @param {object} aInfo + * The initialization info. + * @param {object} [aInfo.node] + * If aInfo.uris is not specified, this must be specified. + * Either a result node or a node-like object representing the item to be edited. + * A node-like object must have the following properties (with values that + * match exactly those a result node would have): + * bookmarkGuid, uri, title, type, … + * @param {nsIURI[]} [aInfo.uris] + * If aInfo.node is not specified, this must be specified. + * An array of uris for bulk tagging. + * @param {string[]} [aInfo.hiddenRows] + * List of rows to be hidden regardless of the item edited. Possible values: + * "title", "location", "keyword", "folderPicker". + */ + async initPanel(aInfo) { + const deferred = (this._initPanelDeferred = Promise.withResolvers()); + try { + if (typeof aInfo != "object" || aInfo === null) { + throw new Error("aInfo must be an object."); + } + if ("node" in aInfo) { + try { + aInfo.node.type; + } catch (e) { + // If the lazy loader for |type| generates an exception, it means that + // this bookmark could not be loaded. This sometimes happens when tests + // create a bookmark by clicking the bookmark star, then try to cleanup + // before the bookmark panel has finished opening. Either way, if we + // cannot retrieve the bookmark information, we cannot open the panel. + return; + } + } + + // For sanity ensure that the implementer has uninited the panel before + // trying to init it again, or we could end up leaking due to observers. + if (this.initialized) { + this.uninitPanel(false); + } + + this._didChangeFolder = false; + this.transactionPromises = []; + + let { + parentGuid, + isItem, + isURI, + isBookmark, + bulkTagging, + uris, + visibleRows, + focusedElement, + onPanelReady, + } = this._setPaneInfo(aInfo); + + // initPanel can be called multiple times in a row, + // and awaits Promises. If the reference to `instance` + // changes, it must mean another caller has called + // initPanel again, so bail out of the initialization. + let instance = (this._instance = {}); + + // If we're creating a new item on the toolbar, show it: + if ( + aInfo.isNewBookmark && + parentGuid == PlacesUtils.bookmarks.toolbarGuid + ) { + this._autoshowBookmarksToolbar(); + } + + let showOrCollapse = ( + rowId, + isAppropriateForInput, + nameInHiddenRows = null + ) => { + let visible = isAppropriateForInput; + if (visible && "hiddenRows" in aInfo && nameInHiddenRows) { + visible &= !aInfo.hiddenRows.includes(nameInHiddenRows); + } + if (visible) { + visibleRows.add(rowId); + } + const cells = document.getElementsByClassName("editBMPanel_" + rowId); + for (const cell of cells) { + cell.hidden = !visible; + } + return visible; + }; + + if (showOrCollapse("nameRow", !bulkTagging, "name")) { + this._initNamePicker(); + this._namePicker.readOnly = this.readOnly; + } + + // In some cases we want to hide the location field, since it's not + // human-readable, but we still want to initialize it. + showOrCollapse("locationRow", isURI, "location"); + if (isURI) { + this._initLocationField(); + this._locationField.readOnly = this.readOnly; + } + + if (showOrCollapse("keywordRow", isBookmark, "keyword")) { + await this._initKeywordField().catch(console.error); + // paneInfo can be null if paneInfo is uninitialized while + // the process above is awaiting initialization + if (instance != this._instance || this._paneInfo == null) { + return; + } + this._keywordField.readOnly = this.readOnly; + } + + // Collapse the tag selector if the item does not accept tags. + if (showOrCollapse("tagsRow", isBookmark || bulkTagging, "tags")) { + this._initTagsField(); + } else if (!this._element("tagsSelectorRow").hidden) { + this.toggleTagsSelector().catch(console.error); + } + + // Folder picker. + // Technically we should check that the item is not moveable, but that's + // not cheap (we don't always have the parent), and there's no use case for + // this (it's only the Star UI that shows the folderPicker) + if (showOrCollapse("folderRow", isItem, "folderPicker")) { + await this._initFolderMenuList(parentGuid).catch(console.error); + if (instance != this._instance || this._paneInfo == null) { + return; + } + } + + // Selection count. + if (showOrCollapse("selectionCount", bulkTagging)) { + document.l10n.setAttributes( + this._element("itemsCountText"), + "places-details-pane-items-count", + { count: uris.length } + ); + } + + // Observe changes. + if (!this._observersAdded) { + this.handlePlacesEvents = this.handlePlacesEvents.bind(this); + PlacesUtils.observers.addListener( + ["bookmark-title-changed"], + this.handlePlacesEvents + ); + window.addEventListener("unload", this); + this._observersAdded = true; + } + + let focusElement = () => { + // The focusedElement possible values are: + // * preferred: focus the field that the user touched first the last + // time the pane was shown (either namePicker or tagsField) + // * first: focus the first non hidden input + // Note: since all controls are hidden by default, we don't get the + // default XUL dialog behavior, that selects the first control, so we set + // the focus explicitly. + + let elt; + if (focusedElement === "preferred") { + elt = this._element( + Services.prefs.getCharPref( + "browser.bookmarks.editDialog.firstEditField" + ) + ); + if (elt.parentNode.hidden) { + focusedElement = "first"; + } + } + if (focusedElement === "first") { + elt = document + .getElementById("editBookmarkPanelContent") + .querySelector('input:not([hidden="true"])'); + } + + if (elt) { + elt.focus({ preventScroll: true }); + elt.select(); + } + }; + + if (onPanelReady) { + onPanelReady(focusElement); + } else { + focusElement(); + } + + if (this._updateTagsDeferred) { + await this._updateTagsDeferred.promise; + } + + this._bookmarkState = this.makeNewStateObject({ + children: aInfo.node?.children, + index: aInfo.node?.index, + isFolder: aInfo.node != null && PlacesUtils.nodeIsFolder(aInfo.node), + }); + if (isBookmark || bulkTagging) { + await this._initAllTags(); + await this._rebuildTagsSelectorList(); + } + } finally { + deferred.resolve(); + if (this._initPanelDeferred === deferred) { + // Since change listeners check _initPanelDeferred for truthiness, we + // can prevent unnecessary awaits by setting it back to null. + this._initPanelDeferred = null; + } + } + }, + + /** + * Finds tags that are in common among this._currentInfo.uris; + * + * @returns {string[]} + */ + _getCommonTags() { + if ("_cachedCommonTags" in this._paneInfo) { + return this._paneInfo._cachedCommonTags; + } + + let uris = [...this._paneInfo.uris]; + let firstURI = uris.shift(); + let commonTags = new Set(PlacesUtils.tagging.getTagsForURI(firstURI)); + if (commonTags.size == 0) { + return (this._cachedCommonTags = []); + } + + for (let uri of uris) { + let curentURITags = PlacesUtils.tagging.getTagsForURI(uri); + for (let tag of commonTags) { + if (!curentURITags.includes(tag)) { + commonTags.delete(tag); + if (commonTags.size == 0) { + return (this._paneInfo.cachedCommonTags = []); + } + } + } + } + return (this._paneInfo._cachedCommonTags = [...commonTags]); + }, + + _initTextField(aElement, aValue) { + if (aElement.value != aValue) { + aElement.value = aValue; + + // Clear the editor's undo stack + // FYI: editor may be null. + aElement.editor?.clearUndoRedo(); + } + }, + + /** + * Appends a menu-item representing a bookmarks folder to a menu-popup. + * + * @param {DOMElement} aMenupopup + * The popup to which the menu-item should be added. + * @param {string} aFolderGuid + * The identifier of the bookmarks folder. + * @param {string} aTitle + * The title to use as a label. + * @returns {DOMElement} + * The new menu item. + */ + _appendFolderItemToMenupopup(aMenupopup, aFolderGuid, aTitle) { + // First make sure the folders-separator is visible + this._element("foldersSeparator").hidden = false; + + var folderMenuItem = document.createXULElement("menuitem"); + folderMenuItem.folderGuid = aFolderGuid; + folderMenuItem.setAttribute("label", aTitle); + folderMenuItem.className = "menuitem-iconic folder-icon"; + aMenupopup.appendChild(folderMenuItem); + return folderMenuItem; + }, + + async _initFolderMenuList(aSelectedFolderGuid) { + // clean up first + var menupopup = this._folderMenuList.menupopup; + while (menupopup.children.length > 6) { + menupopup.removeChild(menupopup.lastElementChild); + } + + // Build the static list + if (!this._staticFoldersListBuilt) { + let unfiledItem = this._element("unfiledRootItem"); + unfiledItem.label = PlacesUtils.getString("OtherBookmarksFolderTitle"); + unfiledItem.folderGuid = PlacesUtils.bookmarks.unfiledGuid; + let bmMenuItem = this._element("bmRootItem"); + bmMenuItem.label = PlacesUtils.getString("BookmarksMenuFolderTitle"); + bmMenuItem.folderGuid = PlacesUtils.bookmarks.menuGuid; + let toolbarItem = this._element("toolbarFolderItem"); + toolbarItem.label = PlacesUtils.getString("BookmarksToolbarFolderTitle"); + toolbarItem.folderGuid = PlacesUtils.bookmarks.toolbarGuid; + this._staticFoldersListBuilt = true; + } + + // List of recently used folders: + let lastUsedFolderGuids = await PlacesUtils.metadata.get( + PlacesUIUtils.LAST_USED_FOLDERS_META_KEY, + [] + ); + + /** + * The list of last used folders is sorted in most-recent first order. + * + * First we build the annotated folders array, each item has both the + * folder identifier and the time at which it was last-used by this dialog + * set. Then we sort it descendingly based on the time field. + */ + this._recentFolders = []; + for (let guid of lastUsedFolderGuids) { + let bm = await PlacesUtils.bookmarks.fetch(guid); + if (bm) { + let title = PlacesUtils.bookmarks.getLocalizedTitle(bm); + this._recentFolders.push({ guid, title }); + } + } + + var numberOfItems = Math.min( + PlacesUIUtils.maxRecentFolders, + this._recentFolders.length + ); + for (let i = 0; i < numberOfItems; i++) { + await this._appendFolderItemToMenupopup( + menupopup, + this._recentFolders[i].guid, + this._recentFolders[i].title + ); + } + + let title = (await PlacesUtils.bookmarks.fetch(aSelectedFolderGuid)).title; + var defaultItem = this._getFolderMenuItem(aSelectedFolderGuid, title); + this._folderMenuList.selectedItem = defaultItem; + // Ensure the selectedGuid attribute is set correctly (the above line wouldn't + // necessary trigger a select event, so handle it manually, then add the + // listener). + this._onFolderListSelected(); + + this._folderMenuList.addEventListener("select", this); + this._folderMenuListListenerAdded = true; + + // Hide the folders-separator if no folder is annotated as recently-used + this._element("foldersSeparator").hidden = menupopup.children.length <= 6; + this._folderMenuList.disabled = this.readOnly; + }, + + _onFolderListSelected() { + // Set a selectedGuid attribute to show special icons + let folderGuid = this.selectedFolderGuid; + if (folderGuid) { + this._folderMenuList.setAttribute("selectedGuid", folderGuid); + } else { + this._folderMenuList.removeAttribute("selectedGuid"); + } + }, + + _element(aID) { + return document.getElementById("editBMPanel_" + aID); + }, + + uninitPanel(aHideCollapsibleElements) { + if (aHideCollapsibleElements) { + // Hide the folder tree if it was previously visible. + var folderTreeRow = this._element("folderTreeRow"); + if (!folderTreeRow.hidden) { + this.toggleFolderTreeVisibility(); + } + + // Hide the tag selector if it was previously visible. + var tagsSelectorRow = this._element("tagsSelectorRow"); + if (!tagsSelectorRow.hidden) { + this.toggleTagsSelector().catch(console.error); + } + } + + if (this._observersAdded) { + PlacesUtils.observers.removeListener( + ["bookmark-title-changed"], + this.handlePlacesEvents + ); + window.removeEventListener("unload", this); + this._observersAdded = false; + } + + if (this._folderMenuListListenerAdded) { + this._folderMenuList.removeEventListener("select", this); + this._folderMenuListListenerAdded = false; + } + + this._setPaneInfo(null); + this._firstEditedField = ""; + this._didChangeFolder = false; + this.transactionPromises = []; + this._bookmarkState = null; + this._allTags = null; + }, + + get selectedFolderGuid() { + return ( + this._folderMenuList.selectedItem && + this._folderMenuList.selectedItem.folderGuid + ); + }, + + makeNewStateObject(extraOptions) { + if ( + this._paneInfo.isItem || + this._paneInfo.isTag || + this._paneInfo.bulkTagging + ) { + const isLibraryWindow = + document.documentElement.getAttribute("windowtype") === + "Places:Organizer"; + const options = { + autosave: isLibraryWindow, + info: this._paneInfo, + ...extraOptions, + }; + + if (this._paneInfo.isBookmark) { + options.tags = this._element("tagsField").value; + options.keyword = this._keyword; + } + + if (this._paneInfo.bulkTagging) { + options.tags = this._element("tagsField").value; + } + + return new PlacesUIUtils.BookmarkState(options); + } + return null; + }, + + async onTagsFieldChange() { + // Check for _paneInfo existing as the dialog may be closing but receiving + // async updates from unresolved promises. + if ( + this._paneInfo && + (this._paneInfo.isURI || this._paneInfo.bulkTagging) + ) { + if (this._initPanelDeferred) { + await this._initPanelDeferred.promise; + } + this._updateTags().then(() => { + // Check _paneInfo here as we might be closing the dialog. + if (this._paneInfo) { + this._mayUpdateFirstEditField("tagsField"); + } + }, console.error); + } + }, + + /** + * Handle tag list updates from the input field or selector box. + */ + async _updateTags() { + const deferred = (this._updateTagsDeferred = Promise.withResolvers()); + try { + const inputTags = this._getTagsArrayFromTagsInputField(); + const isLibraryWindow = + document.documentElement.getAttribute("windowtype") === + "Places:Organizer"; + await this._bookmarkState._tagsChanged(inputTags); + + if (isLibraryWindow) { + // Ensure the tagsField is in sync, clean it up from empty tags + delete this._paneInfo._cachedCommonTags; + const currentTags = this._paneInfo.bulkTagging + ? this._getCommonTags() + : PlacesUtils.tagging.getTagsForURI(this._paneInfo.uri); + this._initTextField(this._tagsField, currentTags.join(", "), false); + await this._initAllTags(); + } else { + // Autosave is disabled. Update _allTags in memory so that the selector + // list shows any new tags that haven't been saved yet. + inputTags.forEach(tag => this._allTags?.set(tag.toLowerCase(), tag)); + } + await this._rebuildTagsSelectorList(); + } finally { + deferred.resolve(); + if (this._updateTagsDeferred === deferred) { + // Since initPanel() checks _updateTagsDeferred for truthiness, we can + // prevent unnecessary awaits by setting it back to null. + this._updateTagsDeferred = null; + } + } + }, + + /** + * Stores the first-edit field for this dialog, if the passed-in field + * is indeed the first edited field. + * + * @param {string} aNewField + * The id of the field that may be set (without the "editBMPanel_" prefix). + */ + _mayUpdateFirstEditField(aNewField) { + // * The first-edit-field behavior is not applied in the multi-edit case + // * if this._firstEditedField is already set, this is not the first field, + // so there's nothing to do + if (this._paneInfo.bulkTagging || this._firstEditedField) { + return; + } + + this._firstEditedField = aNewField; + + // set the pref + Services.prefs.setCharPref( + "browser.bookmarks.editDialog.firstEditField", + aNewField + ); + }, + + async onNamePickerChange() { + if (this.readOnly || !(this._paneInfo.isItem || this._paneInfo.isTag)) { + return; + } + if (this._initPanelDeferred) { + await this._initPanelDeferred.promise; + } + + // Here we update either the item title or its cached static title + if (this._paneInfo.isTag) { + let tag = this._namePicker.value; + if (!tag || tag.includes("&")) { + // We don't allow setting an empty title for a tag, restore the old one. + this._initNamePicker(); + return; + } + + this._bookmarkState._titleChanged(tag); + return; + } + this._mayUpdateFirstEditField("namePicker"); + this._bookmarkState._titleChanged(this._namePicker.value); + }, + + async onLocationFieldChange() { + if (this.readOnly || !this._paneInfo.isBookmark) { + return; + } + if (this._initPanelDeferred) { + await this._initPanelDeferred.promise; + } + + let newURI; + try { + newURI = Services.uriFixup.getFixupURIInfo( + this._locationField.value + ).preferredURI; + } catch (ex) { + // TODO: Bug 1089141 - Provide some feedback about the invalid url. + return; + } + + if (this._paneInfo.uri.equals(newURI)) { + return; + } + this._bookmarkState._locationChanged(newURI.spec); + }, + + async onKeywordFieldChange() { + if (this.readOnly || !this._paneInfo.isBookmark) { + return; + } + if (this._initPanelDeferred) { + await this._initPanelDeferred.promise; + } + this._bookmarkState._keywordChanged(this._keywordField.value); + }, + + toggleFolderTreeVisibility() { + let expander = this._element("foldersExpander"); + let folderTreeRow = this._element("folderTreeRow"); + let wasHidden = folderTreeRow.hidden; + expander.classList.toggle("expander-up", wasHidden); + expander.classList.toggle("expander-down", !wasHidden); + if (!wasHidden) { + document.l10n.setAttributes( + expander, + "bookmark-overlay-folders-expander2" + ); + folderTreeRow.hidden = true; + this._element("chooseFolderSeparator").hidden = this._element( + "chooseFolderMenuItem" + ).hidden = false; + // Stop editing if we were (will no-op if not). This avoids permanently + // breaking the tree if/when it is reshown. + this._folderTree.stopEditing(false); + // Unlinking the view will break the connection with the result. We don't + // want to pay for live updates while the view is not visible. + this._folderTree.view = null; + } else { + document.l10n.setAttributes( + expander, + "bookmark-overlay-folders-expander-hide" + ); + folderTreeRow.hidden = false; + + // XXXmano: Ideally we would only do this once, but for some odd reason, + // the editable mode set on this tree, together with its hidden state + // breaks the view. + const FOLDER_TREE_PLACE_URI = + "place:excludeItems=1&excludeQueries=1&type=" + + Ci.nsINavHistoryQueryOptions.RESULTS_AS_ROOTS_QUERY; + this._folderTree.place = FOLDER_TREE_PLACE_URI; + + this._element("chooseFolderSeparator").hidden = this._element( + "chooseFolderMenuItem" + ).hidden = true; + this._folderTree.selectItems([this._bookmarkState.parentGuid]); + this._folderTree.focus(); + } + }, + + /** + * Get the corresponding menu-item in the folder-menu-list for a bookmarks + * folder if such an item exists. Otherwise, this creates a menu-item for the + * folder. If the items-count limit (see + * browser.bookmarks.editDialog.maxRecentFolders preference) is reached, the + * new item replaces the last menu-item. + * + * @param {string} aFolderGuid + * The identifier of the bookmarks folder. + * @param {string} aTitle + * The title to use in case of menuitem creation. + * @returns {DOMElement} + * The handle to the menuitem. + */ + _getFolderMenuItem(aFolderGuid, aTitle) { + let menupopup = this._folderMenuList.menupopup; + let menuItem = Array.prototype.find.call( + menupopup.children, + item => item.folderGuid === aFolderGuid + ); + if (menuItem !== undefined) { + return menuItem; + } + + // 3 special folders + separator + folder-items-count limit + if (menupopup.children.length == 4 + PlacesUIUtils.maxRecentFolders) { + menupopup.removeChild(menupopup.lastElementChild); + } + + return this._appendFolderItemToMenupopup(menupopup, aFolderGuid, aTitle); + }, + + async onFolderMenuListCommand(aEvent) { + // Check for _paneInfo existing as the dialog may be closing but receiving + // async updates from unresolved promises. + if (!this._paneInfo) { + return; + } + + if (aEvent.target.id == "editBMPanel_chooseFolderMenuItem") { + // reset the selection back to where it was and expand the tree + // (this menu-item is hidden when the tree is already visible + let item = this._getFolderMenuItem( + this._bookmarkState._originalState.parentGuid, + this._bookmarkState._originalState.title + ); + this._folderMenuList.selectedItem = item; + // XXXmano HACK: setTimeout 100, otherwise focus goes back to the + // menulist right away + setTimeout(() => this.toggleFolderTreeVisibility(), 100); + return; + } + + // Move the item + let containerGuid = this._folderMenuList.selectedItem.folderGuid; + if (this._bookmarkState.parentGuid != containerGuid) { + this._bookmarkState._parentGuidChanged(containerGuid); + + // Auto-show the bookmarks toolbar when adding / moving an item there. + if (containerGuid == PlacesUtils.bookmarks.toolbarGuid) { + this._autoshowBookmarksToolbar(); + } + + // Unless the user cancels the panel, we'll use the chosen folder as + // the default for new bookmarks. + this._didChangeFolder = true; + } + + // Update folder-tree selection + var folderTreeRow = this._element("folderTreeRow"); + if (!folderTreeRow.hidden) { + var selectedNode = this._folderTree.selectedNode; + if ( + !selectedNode || + PlacesUtils.getConcreteItemGuid(selectedNode) != containerGuid + ) { + this._folderTree.selectItems([containerGuid]); + } + } + }, + + _autoshowBookmarksToolbar() { + let neverShowToolbar = + Services.prefs.getCharPref( + "browser.toolbars.bookmarks.visibility", + "newtab" + ) == "never"; + let toolbar = document.getElementById("PersonalToolbar"); + if (!toolbar.collapsed || neverShowToolbar) { + return; + } + + let placement = CustomizableUI.getPlacementOfWidget("personal-bookmarks"); + let area = placement && placement.area; + if (area != CustomizableUI.AREA_BOOKMARKS) { + return; + } + + // Show the toolbar but don't persist it permanently open + setToolbarVisibility(toolbar, true, false); + }, + + onFolderTreeSelect() { + // Ignore this event when the folder tree is hidden, even if the tree is + // alive, it's clearly not a user activated action. + if (this._element("folderTreeRow").hidden) { + return; + } + + var selectedNode = this._folderTree.selectedNode; + + // Disable the "New Folder" button if we cannot create a new folder + this._element("newFolderButton").disabled = + !this._folderTree.insertionPoint || !selectedNode; + + if (!selectedNode) { + return; + } + + var folderGuid = PlacesUtils.getConcreteItemGuid(selectedNode); + if (this._folderMenuList.selectedItem.folderGuid == folderGuid) { + return; + } + + var folderItem = this._getFolderMenuItem(folderGuid, selectedNode.title); + this._folderMenuList.selectedItem = folderItem; + folderItem.doCommand(); + }, + + async _rebuildTagsSelectorList() { + let tagsSelector = this._element("tagsSelector"); + let tagsSelectorRow = this._element("tagsSelectorRow"); + if (tagsSelectorRow.hidden) { + return; + } + + let selectedIndex = tagsSelector.selectedIndex; + let selectedTag = + selectedIndex >= 0 ? tagsSelector.selectedItem.label : null; + + while (tagsSelector.hasChildNodes()) { + tagsSelector.removeChild(tagsSelector.lastElementChild); + } + + let tagsInField = this._getTagsArrayFromTagsInputField(); + + let fragment = document.createDocumentFragment(); + let sortedTags = this._allTags ? [...this._allTags.values()].sort() : []; + + for (let i = 0; i < sortedTags.length; i++) { + let tag = sortedTags[i]; + let elt = document.createXULElement("richlistitem"); + elt.appendChild(document.createXULElement("image")); + let label = document.createXULElement("label"); + label.setAttribute("value", tag); + elt.appendChild(label); + if (tagsInField.includes(tag)) { + elt.setAttribute("checked", "true"); + } + fragment.appendChild(elt); + if (selectedTag === tag) { + selectedIndex = i; + } + } + tagsSelector.appendChild(fragment); + + if (selectedIndex >= 0 && tagsSelector.itemCount > 0) { + selectedIndex = Math.min(selectedIndex, tagsSelector.itemCount - 1); + tagsSelector.selectedIndex = selectedIndex; + tagsSelector.ensureIndexIsVisible(selectedIndex); + } + let event = new CustomEvent("BookmarkTagsSelectorUpdated", { + bubbles: true, + }); + tagsSelector.dispatchEvent(event); + }, + + async toggleTagsSelector() { + var tagsSelector = this._element("tagsSelector"); + var tagsSelectorRow = this._element("tagsSelectorRow"); + var expander = this._element("tagsSelectorExpander"); + expander.classList.toggle("expander-up", tagsSelectorRow.hidden); + expander.classList.toggle("expander-down", !tagsSelectorRow.hidden); + if (tagsSelectorRow.hidden) { + document.l10n.setAttributes( + expander, + "bookmark-overlay-tags-expander-hide" + ); + tagsSelectorRow.hidden = false; + await this._rebuildTagsSelectorList(); + + // This is a no-op if we've added the listener. + tagsSelector.addEventListener("mousedown", this); + tagsSelector.addEventListener("keypress", this); + } else { + document.l10n.setAttributes(expander, "bookmark-overlay-tags-expander2"); + tagsSelectorRow.hidden = true; + + // This is a no-op if we've removed the listener. + tagsSelector.removeEventListener("mousedown", this); + tagsSelector.removeEventListener("keypress", this); + } + }, + + /** + * Splits "tagsField" element value, returning an array of valid tag strings. + * + * @returns {string[]} + * Array of tag strings found in the field value. + */ + _getTagsArrayFromTagsInputField() { + let tags = this._element("tagsField").value; + return tags + .trim() + .split(/\s*,\s*/) // Split on commas and remove spaces. + .filter(tag => !!tag.length); // Kill empty tags. + }, + + async newFolder() { + let ip = this._folderTree.insertionPoint; + + // default to the bookmarks menu folder + if (!ip) { + ip = new PlacesInsertionPoint({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + }); + } + + // XXXmano: add a separate "New Folder" string at some point... + let title = this._element("newFolderButton").label; + let promise = PlacesTransactions.NewFolder({ + parentGuid: ip.guid, + title, + index: await ip.getIndex(), + }).transact(); + this.transactionPromises.push(promise.catch(console.error)); + let guid = await promise; + + this._folderTree.focus(); + this._folderTree.selectItems([ip.guid]); + PlacesUtils.asContainer(this._folderTree.selectedNode).containerOpen = true; + this._folderTree.selectItems([guid]); + this._folderTree.startEditing( + this._folderTree.view.selection.currentIndex, + this._folderTree.columns.getFirstColumn() + ); + }, + + // EventListener + handleEvent(event) { + switch (event.type) { + case "mousedown": + if (event.button == 0) { + // Make sure the event is triggered on an item and not the empty space. + let item = event.target.closest("richlistbox,richlistitem"); + if (item.localName == "richlistitem") { + this.toggleItemCheckbox(item); + } + } + break; + case "keypress": + if (event.key == " ") { + let item = event.target.currentItem; + if (item) { + this.toggleItemCheckbox(item); + } + } + break; + case "unload": + this.uninitPanel(false); + break; + case "select": + this._onFolderListSelected(); + break; + } + }, + + async handlePlacesEvents(events) { + for (const event of events) { + switch (event.type) { + case "bookmark-title-changed": + if (this._paneInfo.isItem || this._paneInfo.isTag) { + // This also updates titles of folders in the folder menu list. + this._onItemTitleChange(event.id, event.title, event.guid); + } + break; + } + } + }, + + toggleItemCheckbox(item) { + // Update the tags field when items are checked/unchecked in the listbox + let tags = this._getTagsArrayFromTagsInputField(); + + let curTagIndex = tags.indexOf(item.label); + let tagsSelector = this._element("tagsSelector"); + tagsSelector.selectedItem = item; + + if (!item.hasAttribute("checked")) { + item.setAttribute("checked", "true"); + if (curTagIndex == -1) { + tags.push(item.label); + } + } else { + item.removeAttribute("checked"); + if (curTagIndex != -1) { + tags.splice(curTagIndex, 1); + } + } + this._element("tagsField").value = tags.join(", "); + this._updateTags(); + }, + + _initTagsField() { + let tags; + if (this._paneInfo.isURI) { + tags = PlacesUtils.tagging.getTagsForURI(this._paneInfo.uri); + } else if (this._paneInfo.bulkTagging) { + tags = this._getCommonTags(); + } else { + throw new Error("_promiseTagsStr called unexpectedly"); + } + + this._initTextField(this._tagsField, tags.join(", ")); + }, + + _onItemTitleChange(aItemId, aNewTitle, aGuid) { + if (this._paneInfo.visibleRows.has("folderRow")) { + // If the title of a folder which is listed within the folders + // menulist has been changed, we need to update the label of its + // representing element. + let menupopup = this._folderMenuList.menupopup; + for (let menuitem of menupopup.children) { + if ("folderGuid" in menuitem && menuitem.folderGuid == aGuid) { + menuitem.label = aNewTitle; + break; + } + } + } + // We need to also update title of recent folders. + if (this._recentFolders) { + for (let folder of this._recentFolders) { + if (folder.folderGuid == aGuid) { + folder.title = aNewTitle; + break; + } + } + } + }, + + /** + * State object for the bookmark(s) currently being edited. + * + * @returns {BookmarkState} The bookmark state. + */ + get bookmarkState() { + return this._bookmarkState; + }, +}; + +ChromeUtils.defineLazyGetter(gEditItemOverlay, "_folderTree", () => { + if (!customElements.get("places-tree")) { + Services.scriptloader.loadSubScript( + "chrome://browser/content/places/places-tree.js", + window + ); + } + gEditItemOverlay._element("folderTreeRow").prepend( + MozXULElement.parseXULToFragment(` + <tree id="editBMPanel_folderTree" + class="placesTree" + is="places-tree" + data-l10n-id="bookmark-overlay-folders-tree" + editable="true" + onselect="gEditItemOverlay.onFolderTreeSelect();" + disableUserActions="true" + hidecolumnpicker="true"> + <treecols> + <treecol anonid="title" flex="1" primary="true" hideheader="true"/> + </treecols> + <treechildren flex="1"/> + </tree> + `) + ); + return gEditItemOverlay._element("folderTree"); +}); + +for (let elt of [ + "folderMenuList", + "namePicker", + "locationField", + "keywordField", + "tagsField", +]) { + let eltScoped = elt; + ChromeUtils.defineLazyGetter(gEditItemOverlay, `_${eltScoped}`, () => + gEditItemOverlay._element(eltScoped) + ); +} diff --git a/browser/components/places/content/editBookmarkPanel.inc.xhtml b/browser/components/places/content/editBookmarkPanel.inc.xhtml new file mode 100644 index 0000000000..3ec3f09483 --- /dev/null +++ b/browser/components/places/content/editBookmarkPanel.inc.xhtml @@ -0,0 +1,122 @@ +# 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/. + +<div id="editBookmarkPanelContent"> + <label id="editBMPanel_itemsCountText" + class="editBMPanel_selectionCount"/> + + <label data-l10n-id="bookmark-overlay-name-2" + class="editBMPanel_nameRow hideable" + control="editBMPanel_namePicker"/> + <html:input id="editBMPanel_namePicker" + class="editBMPanel_nameRow hideable" + type="text" + onchange="gEditItemOverlay.onNamePickerChange().catch(Cu.reportError);"/> + + <label data-l10n-id="bookmark-overlay-url" + class="editBMPanel_locationRow hideable" + control="editBMPanel_locationField"/> + <html:input id="editBMPanel_locationField" + class="editBMPanel_locationRow uri-element hideable" + type="text" + onchange="gEditItemOverlay.onLocationFieldChange();"/> + + <label data-l10n-id="bookmark-overlay-location-2" + class="editBMPanel_folderRow hideable" + control="editBMPanel_folderMenuList"/> + <hbox class="editBMPanel_folderRow hideable"> + <menulist id="editBMPanel_folderMenuList" + class="folder-icon" + flex="1" + size="large" + oncommand="gEditItemOverlay.onFolderMenuListCommand(event).catch(Cu.reportError);"> + <menupopup> + <!-- Static item for special folders --> + <menuitem id="editBMPanel_toolbarFolderItem" + class="menuitem-iconic folder-icon"/> + <menuitem id="editBMPanel_bmRootItem" + class="menuitem-iconic folder-icon"/> + <menuitem id="editBMPanel_unfiledRootItem" + class="menuitem-iconic folder-icon"/> + <menuseparator id="editBMPanel_chooseFolderSeparator"/> + <menuitem id="editBMPanel_chooseFolderMenuItem" + data-l10n-id="bookmark-overlay-choose" + class="menuitem-iconic folder-icon"/> + <menuseparator id="editBMPanel_foldersSeparator" hidden="true"/> + </menupopup> + </menulist> + <button id="editBMPanel_foldersExpander" + class="expander-down panel-button" + data-l10n-id="bookmark-overlay-folders-expander2" + oncommand="gEditItemOverlay.toggleFolderTreeVisibility();"/> + </hbox> + + <vbox id="editBMPanel_folderTreeRow" + class="hideable" + hidden="true"> + <!-- editBMPanel_folderTree will go here when this is shown --> + <hbox id="editBMPanel_newFolderBox"> + <button data-l10n-id="bookmark-overlay-new-folder-button" + id="editBMPanel_newFolderButton" + oncommand="gEditItemOverlay.newFolder().catch(Cu.reportError);"/> + </hbox> + </vbox> + + <label data-l10n-id="bookmark-overlay-tags-2" + class="editBMPanel_tagsRow hideable" + control="editBMPanel_tagsField"/> + <hbox class="editBMPanel_tagsRow hideable"> + <html:input id="editBMPanel_tagsField" + type="text" + is="autocomplete-input" + style="flex: 1;" + autocompletesearch="places-tag-autocomplete" + autocompletepopup="editBMPanel_tagsAutocomplete" + completedefaultindex="true" + completeselectedindex="true" + tabscrolling="true" + data-l10n-id="bookmark-overlay-tags-empty-description" + data-l10n-attrs="placeholder" + aria-describedby="tags-field-info" + onchange="gEditItemOverlay.onTagsFieldChange();"/> + <popupset> + <panel is="autocomplete-richlistbox-popup" + type="autocomplete-richlistbox" + id="editBMPanel_tagsAutocomplete" + role="group" + noautofocus="true" + hidden="true" + overflowpadding="4" + norolluponanchor="true" + nomaxresults="true"/> + </popupset> + <button id="editBMPanel_tagsSelectorExpander" + class="expander-down panel-button" + data-l10n-id="bookmark-overlay-tags-expander2" + oncommand="gEditItemOverlay.toggleTagsSelector().catch(Cu.reportError);"/> + </hbox> + + <div id="tags-field-info" + class="editBMPanel_tagsRow caption-label hideable" + data-l10n-id="bookmark-overlay-tags-caption-label"/> + + <div id="editBMPanel_tagsSelectorRow" + class="hideable" + hidden="true"> + <richlistbox id="editBMPanel_tagsSelector" styled="true"/> + </div> + + <label data-l10n-id="bookmark-overlay-keyword-2" + class="editBMPanel_keywordRow hideable" + control="editBMPanel_keywordField"/> + <html:input id="editBMPanel_keywordField" + class="editBMPanel_keywordRow hideable" + type="text" + aria-describedby="keyword-field-info" + onchange="gEditItemOverlay.onKeywordFieldChange();"/> + + <div id="keyword-field-info" + class="editBMPanel_keywordRow caption-label hideable" + data-l10n-id="bookmark-overlay-keyword-caption-label-2"/> +</div> diff --git a/browser/components/places/content/historySidebar.js b/browser/components/places/content/historySidebar.js new file mode 100644 index 0000000000..f5af2c860a --- /dev/null +++ b/browser/components/places/content/historySidebar.js @@ -0,0 +1,171 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 4 -*- */ +/* 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/. */ + +/* Shared Places Import - change other consumers if you change this: */ +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + PlacesTransactions: "resource://gre/modules/PlacesTransactions.sys.mjs", + PlacesUIUtils: "resource:///modules/PlacesUIUtils.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyScriptGetter( + this, + "PlacesTreeView", + "chrome://browser/content/places/treeView.js" +); +XPCOMUtils.defineLazyScriptGetter( + this, + ["PlacesInsertionPoint", "PlacesController", "PlacesControllerDragHelper"], + "chrome://browser/content/places/controller.js" +); +/* End Shared Places Import */ + +var gHistoryTree; +var gSearchBox; +var gHistoryGrouping = ""; +var gCumulativeSearches = 0; +var gCumulativeFilterCount = 0; + +function HistorySidebarInit() { + let uidensity = window.top.document.documentElement.getAttribute("uidensity"); + if (uidensity) { + document.documentElement.setAttribute("uidensity", uidensity); + } + + gHistoryTree = document.getElementById("historyTree"); + gSearchBox = document.getElementById("search-box"); + + gHistoryGrouping = document + .getElementById("viewButton") + .getAttribute("selectedsort"); + + this.groupHistogram = Services.telemetry.getHistogramById( + "PLACES_SEARCHBAR_FILTER_TYPE" + ); + this.groupHistogram.add(gHistoryGrouping); + + if (gHistoryGrouping == "site") { + document.getElementById("bysite").setAttribute("checked", "true"); + } else if (gHistoryGrouping == "visited") { + document.getElementById("byvisited").setAttribute("checked", "true"); + } else if (gHistoryGrouping == "lastvisited") { + document.getElementById("bylastvisited").setAttribute("checked", "true"); + } else if (gHistoryGrouping == "dayandsite") { + document.getElementById("bydayandsite").setAttribute("checked", "true"); + } else { + document.getElementById("byday").setAttribute("checked", "true"); + } + + searchHistory(""); +} + +function GroupBy(groupingType) { + if (groupingType != gHistoryGrouping) { + this.groupHistogram.add(groupingType); + } + gHistoryGrouping = groupingType; + gCumulativeFilterCount++; + searchHistory(gSearchBox.value); +} + +function updateTelemetry(urlsOpened = []) { + let searchesHistogram = Services.telemetry.getHistogramById( + "PLACES_SEARCHBAR_CUMULATIVE_SEARCHES" + ); + searchesHistogram.add(gCumulativeSearches); + let filterCountHistogram = Services.telemetry.getHistogramById( + "PLACES_SEARCHBAR_CUMULATIVE_FILTER_COUNT" + ); + filterCountHistogram.add(gCumulativeFilterCount); + clearCumulativeCounters(); + + Services.telemetry.keyedScalarAdd( + "sidebar.link", + "history", + urlsOpened.length + ); +} + +function searchHistory(aInput) { + var query = PlacesUtils.history.getNewQuery(); + var options = PlacesUtils.history.getNewQueryOptions(); + + const NHQO = Ci.nsINavHistoryQueryOptions; + var sortingMode; + var resultType; + + switch (gHistoryGrouping) { + case "visited": + resultType = NHQO.RESULTS_AS_URI; + sortingMode = NHQO.SORT_BY_VISITCOUNT_DESCENDING; + break; + case "lastvisited": + resultType = NHQO.RESULTS_AS_URI; + sortingMode = NHQO.SORT_BY_DATE_DESCENDING; + break; + case "dayandsite": + resultType = NHQO.RESULTS_AS_DATE_SITE_QUERY; + break; + case "site": + resultType = NHQO.RESULTS_AS_SITE_QUERY; + sortingMode = NHQO.SORT_BY_TITLE_ASCENDING; + break; + case "day": + default: + resultType = NHQO.RESULTS_AS_DATE_QUERY; + break; + } + + if (aInput) { + query.searchTerms = aInput; + if (gHistoryGrouping != "visited" && gHistoryGrouping != "lastvisited") { + sortingMode = NHQO.SORT_BY_FRECENCY_DESCENDING; + resultType = NHQO.RESULTS_AS_URI; + } + } + + options.sortingMode = sortingMode; + options.resultType = resultType; + options.includeHidden = !!aInput; + + if (gHistoryGrouping == "lastvisited") { + TelemetryStopwatch.start("HISTORY_LASTVISITED_TREE_QUERY_TIME_MS"); + } + + // call load() on the tree manually + // instead of setting the place attribute in historySidebar.xhtml + // otherwise, we will end up calling load() twice + gHistoryTree.load(query, options); + + // Sometimes search is activated without an input string. For example, when + // the history sidbar is first opened or when a search filter is selected. + // Since we're trying to measure how often the searchbar was used, we should first + // check if there's an input string before collecting telemetry. + if (aInput) { + Services.telemetry.keyedScalarAdd("sidebar.search", "history", 1); + gCumulativeSearches++; + } + + if (gHistoryGrouping == "lastvisited") { + TelemetryStopwatch.finish("HISTORY_LASTVISITED_TREE_QUERY_TIME_MS"); + } +} + +function clearCumulativeCounters() { + gCumulativeSearches = 0; + gCumulativeFilterCount = 0; +} + +function unloadHistorySidebar() { + clearCumulativeCounters(); + PlacesUIUtils.setMouseoverURL("", window); +} + +window.addEventListener("SidebarFocused", () => gSearchBox.focus()); diff --git a/browser/components/places/content/historySidebar.xhtml b/browser/components/places/content/historySidebar.xhtml new file mode 100644 index 0000000000..a7c775c12c --- /dev/null +++ b/browser/components/places/content/historySidebar.xhtml @@ -0,0 +1,108 @@ +<?xml version="1.0"?> <!-- -*- Mode: xml; indent-tabs-mode: nil; -*- --> +<!-- 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/. --> + +<!DOCTYPE window> + +<window id="history-panel" + class="sidebar-panel" + orient="vertical" + xmlns:html="http://www.w3.org/1999/xhtml" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + onload="HistorySidebarInit();" + onunload="unloadHistorySidebar();" + data-l10n-id="places-history"> + + <script src="chrome://browser/content/places/historySidebar.js"/> + <script src="chrome://global/content/globalOverlay.js"/> + <script src="chrome://browser/content/utilityOverlay.js"/> + <script src="chrome://browser/content/contentTheme.js"/> + <script src="chrome://browser/content/places/places-tree.js"/> + <script src="chrome://global/content/editMenuOverlay.js"/> + + <linkset> + <html:link + rel="stylesheet" + href="chrome://browser/content/places/places.css" + /> + <html:link + rel="stylesheet" + href="chrome://browser/content/usercontext/usercontext.css" + /> + <html:link rel="stylesheet" href="chrome://global/skin/global.css" /> + <html:link + rel="stylesheet" + href="chrome://browser/skin/places/tree-icons.css" + /> + <html:link + rel="stylesheet" + href="chrome://browser/skin/places/sidebar.css" + /> + + <html:link rel="localization" href="toolkit/global/textActions.ftl"/> + <html:link rel="localization" href="browser/places.ftl"/> + </linkset> + +#include placesCommands.inc.xhtml + +#include ../../../../toolkit/content/editMenuKeys.inc.xhtml +#ifdef XP_MACOSX + <keyset id="editMenuKeysExtra"> + <key id="key_delete2" keycode="VK_BACK" command="cmd_delete"/> + </keyset> +#endif + +#include placesContextMenu.inc.xhtml +#include bookmarksHistoryTooltip.inc.xhtml + + <hbox id="sidebar-search-container"> + <search-textbox id="search-box" flex="1" + data-l10n-id="places-history-search" + data-l10n-attrs="placeholder" + aria-controls="historyTree" + oncommand="searchHistory(this.value);"/> + <button id="viewButton" style="min-width:0px !important;" type="menu" + data-l10n-id="places-view" selectedsort="day" + persist="selectedsort"> + <menupopup> + <menuitem id="bydayandsite" + data-l10n-id="places-by-day-and-site" type="radio" + oncommand="this.parentNode.parentNode.setAttribute('selectedsort', 'dayandsite'); GroupBy('dayandsite');"/> + <menuitem id="bysite" + data-l10n-id="places-by-site" + type="radio" + oncommand="this.parentNode.parentNode.setAttribute('selectedsort', 'site'); GroupBy('site');"/> + <menuitem id="byday" + data-l10n-id="places-by-date" + type="radio" + oncommand="this.parentNode.parentNode.setAttribute('selectedsort', 'day'); GroupBy('day');"/> + <menuitem id="byvisited" + data-l10n-id="places-by-most-visited" + type="radio" + oncommand="this.parentNode.parentNode.setAttribute('selectedsort', 'visited'); GroupBy('visited');"/> + <menuitem id="bylastvisited" + data-l10n-id="places-by-last-visited" + type="radio" + oncommand="this.parentNode.parentNode.setAttribute('selectedsort', 'lastvisited'); GroupBy('lastvisited');"/> + </menupopup> + </button> + </hbox> + + <tree id="historyTree" + class="sidebar-placesTree" + flex="1" + is="places-tree" + hidecolumnpicker="true" + context="placesContext" + singleclickopens="true" + onclick="PlacesUIUtils.onSidebarTreeClick(event);" + onkeypress="PlacesUIUtils.onSidebarTreeKeyPress(event);" + onmousemove="PlacesUIUtils.onSidebarTreeMouseMove(event);" + onmouseout="PlacesUIUtils.setMouseoverURL('', window);"> + <treecols> + <treecol id="title" flex="1" primary="true" hideheader="true"/> + </treecols> + <treechildren class="sidebar-placesTreechildren" flex="1" tooltip="bhTooltip"/> + </tree> +</window> diff --git a/browser/components/places/content/places-menupopup.js b/browser/components/places/content/places-menupopup.js new file mode 100644 index 0000000000..1d7615aa27 --- /dev/null +++ b/browser/components/places/content/places-menupopup.js @@ -0,0 +1,693 @@ +/* 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"; + +/* eslint-env mozilla/browser-window */ +/* import-globals-from controller.js */ + +// On Wayland when D&D source popup is closed, +// D&D operation is canceled by window manager. +function closingPopupEndsDrag(popup) { + if (!popup.isWaylandPopup) { + return false; + } + if (popup.isWaylandDragSource) { + return true; + } + for (let childPopup of popup.querySelectorAll("menu > menupopup")) { + if (childPopup.isWaylandDragSource) { + return true; + } + } + return false; +} + +// This is loaded into all XUL windows. Wrap in a block to prevent +// leaking to window scope. +{ + /** + * This class handles the custom element for the places popup menu. + */ + class MozPlacesPopup extends MozElements.MozMenuPopup { + constructor() { + super(); + + const event_names = [ + "DOMMenuItemActive", + "DOMMenuItemInactive", + "dragstart", + "drop", + "dragover", + "dragleave", + "dragend", + ]; + for (let event_name of event_names) { + this.addEventListener(event_name, this); + } + } + + get markup() { + return ` + <html:link rel="stylesheet" href="chrome://global/skin/global.css" /> + <hbox part="drop-indicator-container"> + <vbox part="drop-indicator-bar" hidden="true"> + <image part="drop-indicator"/> + </vbox> + <arrowscrollbox class="menupopup-arrowscrollbox" flex="1" orient="vertical" + exportparts="scrollbox: arrowscrollbox-scrollbox" + smoothscroll="false" part="arrowscrollbox content"> + <html:slot/> + </arrowscrollbox> + </hbox> + `; + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + /** + * Sub-menus should be opened when the mouse drags over them, and closed + * when the mouse drags off. The overFolder object manages opening and + * closing of folders when the mouse hovers. + */ + this._overFolder = { + _self: this, + _folder: { + elt: null, + openTimer: null, + hoverTime: 350, + closeTimer: null, + }, + _closeMenuTimer: null, + + get elt() { + return this._folder.elt; + }, + set elt(val) { + this._folder.elt = val; + }, + + get openTimer() { + return this._folder.openTimer; + }, + set openTimer(val) { + this._folder.openTimer = val; + }, + + get hoverTime() { + return this._folder.hoverTime; + }, + set hoverTime(val) { + this._folder.hoverTime = val; + }, + + get closeTimer() { + return this._folder.closeTimer; + }, + set closeTimer(val) { + this._folder.closeTimer = val; + }, + + get closeMenuTimer() { + return this._closeMenuTimer; + }, + set closeMenuTimer(val) { + this._closeMenuTimer = val; + }, + + setTimer: function OF__setTimer(aTime) { + var timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + timer.initWithCallback(this, aTime, timer.TYPE_ONE_SHOT); + return timer; + }, + + notify: function OF__notify(aTimer) { + // Function to process all timer notifications. + + if (aTimer == this._folder.openTimer) { + // Timer to open a submenu that's being dragged over. + this._folder.elt.lastElementChild.setAttribute( + "autoopened", + "true" + ); + this._folder.elt.lastElementChild.openPopup(); + this._folder.openTimer = null; + } else if (aTimer == this._folder.closeTimer) { + // Timer to close a submenu that's been dragged off of. + // Only close the submenu if the mouse isn't being dragged over any + // of its child menus. + var draggingOverChild = + PlacesControllerDragHelper.draggingOverChildNode( + this._folder.elt + ); + if (draggingOverChild) { + this._folder.elt = null; + } + this.clear(); + + // Close any parent folders which aren't being dragged over. + // (This is necessary because of the above code that keeps a folder + // open while its children are being dragged over.) + if (!draggingOverChild && !closingPopupEndsDrag(this._self)) { + this.closeParentMenus(); + } + } else if (aTimer == this.closeMenuTimer) { + // Timer to close this menu after the drag exit. + var popup = this._self; + // if we are no more dragging we can leave the menu open to allow + // for better D&D bookmark organization + var hidePopup = + PlacesControllerDragHelper.getSession() && + !PlacesControllerDragHelper.draggingOverChildNode( + popup.parentNode + ); + if (hidePopup) { + if (!closingPopupEndsDrag(popup)) { + popup.hidePopup(); + // Close any parent menus that aren't being dragged over; + // otherwise they'll stay open because they couldn't close + // while this menu was being dragged over. + this.closeParentMenus(); + } else if (popup.isWaylandDragSource) { + // Postpone popup hide until drag end on Wayland. + this._closeMenuTimer = this.setTimer(this.hoverTime); + } + } + } + }, + + // Helper function to close all parent menus of this menu, + // as long as none of the parent's children are currently being + // dragged over. + closeParentMenus: function OF__closeParentMenus() { + var popup = this._self; + var parent = popup.parentNode; + while (parent) { + if (parent.localName == "menupopup" && parent._placesNode) { + if ( + PlacesControllerDragHelper.draggingOverChildNode( + parent.parentNode + ) + ) { + break; + } + parent.hidePopup(); + } + parent = parent.parentNode; + } + }, + + // The mouse is no longer dragging over the stored menubutton. + // Close the menubutton, clear out drag styles, and clear all + // timers for opening/closing it. + clear: function OF__clear() { + if (this._folder.elt && this._folder.elt.lastElementChild) { + var popup = this._folder.elt.lastElementChild; + if ( + !popup.hasAttribute("dragover") && + !closingPopupEndsDrag(popup) + ) { + popup.hidePopup(); + } + // remove menuactive style + this._folder.elt.removeAttribute("_moz-menuactive"); + this._folder.elt = null; + } + if (this._folder.openTimer) { + this._folder.openTimer.cancel(); + this._folder.openTimer = null; + } + if (this._folder.closeTimer) { + this._folder.closeTimer.cancel(); + this._folder.closeTimer = null; + } + }, + }; + } + + get _indicatorBar() { + if (!this.__indicatorBar) { + this.__indicatorBar = this.shadowRoot.querySelector( + "[part=drop-indicator-bar]" + ); + } + return this.__indicatorBar; + } + + /** + * This is the view that manages the popup. + * + * @see {@link PlacesUIUtils.getViewForNode} + * @returns {DOMNode} + */ + get _rootView() { + if (!this.__rootView) { + this.__rootView = PlacesUIUtils.getViewForNode(this); + } + return this.__rootView; + } + + /** + * Check if we should hide the drop indicator for the target + * + * @param {object} aEvent + * The event associated with the drop. + * @returns {boolean} + */ + _hideDropIndicator(aEvent) { + let target = aEvent.target; + + // Don't draw the drop indicator outside of markers or if current + // node is not a Places node. + let betweenMarkers = + this._startMarker.compareDocumentPosition(target) & + Node.DOCUMENT_POSITION_FOLLOWING && + this._endMarker.compareDocumentPosition(target) & + Node.DOCUMENT_POSITION_PRECEDING; + + // Hide the dropmarker if current node is not a Places node. + return !(target && target._placesNode && betweenMarkers); + } + + /** + * This function returns information about where to drop when + * dragging over this popup insertion point + * + * @param {object} aEvent + * The event associated with the drop. + * @returns {object|null} + * The associated drop point information. + */ + _getDropPoint(aEvent) { + // Can't drop if the menu isn't a folder + let resultNode = this._placesNode; + + if ( + !PlacesUtils.nodeIsFolder(resultNode) || + this._rootView.controller.disallowInsertion(resultNode) + ) { + return null; + } + + var dropPoint = { ip: null, folderElt: null }; + + // The element we are dragging over + let elt = aEvent.target; + if (elt.localName == "menupopup") { + elt = elt.parentNode; + } + + let eventY = aEvent.clientY; + let { y: eltY, height: eltHeight } = elt.getBoundingClientRect(); + + if (!elt._placesNode) { + // If we are dragging over a non places node drop at the end. + dropPoint.ip = new PlacesInsertionPoint({ + parentGuid: PlacesUtils.getConcreteItemGuid(resultNode), + }); + // We can set folderElt if we are dropping over a static menu that + // has an internal placespopup. + let isMenu = + elt.localName == "menu" || + (elt.localName == "toolbarbutton" && + elt.getAttribute("type") == "menu"); + if ( + isMenu && + elt.lastElementChild && + elt.lastElementChild.hasAttribute("placespopup") + ) { + dropPoint.folderElt = elt; + } + return dropPoint; + } + + let tagName = PlacesUtils.nodeIsTagQuery(elt._placesNode) + ? elt._placesNode.title + : null; + if ( + (PlacesUtils.nodeIsFolder(elt._placesNode) && + !PlacesUIUtils.isFolderReadOnly(elt._placesNode)) || + PlacesUtils.nodeIsTagQuery(elt._placesNode) + ) { + // This is a folder or a tag container. + if (eventY - eltY < eltHeight * 0.2) { + // If mouse is in the top part of the element, drop above folder. + dropPoint.ip = new PlacesInsertionPoint({ + parentGuid: PlacesUtils.getConcreteItemGuid(resultNode), + orientation: Ci.nsITreeView.DROP_BEFORE, + tagName, + dropNearNode: elt._placesNode, + }); + return dropPoint; + } else if (eventY - eltY < eltHeight * 0.8) { + // If mouse is in the middle of the element, drop inside folder. + dropPoint.ip = new PlacesInsertionPoint({ + parentGuid: PlacesUtils.getConcreteItemGuid(elt._placesNode), + tagName, + }); + dropPoint.folderElt = elt; + return dropPoint; + } + } else if (eventY - eltY <= eltHeight / 2) { + // This is a non-folder node or a readonly folder. + // If the mouse is above the middle, drop above this item. + dropPoint.ip = new PlacesInsertionPoint({ + parentGuid: PlacesUtils.getConcreteItemGuid(resultNode), + orientation: Ci.nsITreeView.DROP_BEFORE, + tagName, + dropNearNode: elt._placesNode, + }); + return dropPoint; + } + + // Drop below the item. + dropPoint.ip = new PlacesInsertionPoint({ + parentGuid: PlacesUtils.getConcreteItemGuid(resultNode), + orientation: Ci.nsITreeView.DROP_AFTER, + tagName, + dropNearNode: elt._placesNode, + }); + return dropPoint; + } + + _cleanupDragDetails() { + // Called on dragend and drop. + PlacesControllerDragHelper.currentDropTarget = null; + this._rootView._draggedElt = null; + this.removeAttribute("dragover"); + this.removeAttribute("dragstart"); + this._indicatorBar.hidden = true; + } + + on_DOMMenuItemActive(event) { + if (super.on_DOMMenuItemActive) { + super.on_DOMMenuItemActive(event); + } + + let elt = event.target; + if (elt.parentNode != this) { + return; + } + + if (window.XULBrowserWindow) { + let placesNode = elt._placesNode; + + var linkURI; + if (placesNode && PlacesUtils.nodeIsURI(placesNode)) { + linkURI = placesNode.uri; + } else if (elt.hasAttribute("targetURI")) { + linkURI = elt.getAttribute("targetURI"); + } + + if (linkURI) { + window.XULBrowserWindow.setOverLink(linkURI); + } + } + } + + on_DOMMenuItemInactive(event) { + let elt = event.target; + if (elt.parentNode != this) { + return; + } + + if (window.XULBrowserWindow) { + window.XULBrowserWindow.setOverLink(""); + } + } + + on_dragstart(event) { + let elt = event.target; + if (!elt._placesNode) { + return; + } + + let draggedElt = elt._placesNode; + + // Force a copy action if parent node is a query or we are dragging a + // not-removable node. + if (!this._rootView.controller.canMoveNode(draggedElt)) { + event.dataTransfer.effectAllowed = "copyLink"; + } + + // Activate the view and cache the dragged element. + this._rootView._draggedElt = draggedElt; + this._rootView.controller.setDataTransfer(event); + this.setAttribute("dragstart", "true"); + event.stopPropagation(); + } + + on_drop(event) { + PlacesControllerDragHelper.currentDropTarget = event.target; + + let dropPoint = this._getDropPoint(event); + if (dropPoint && dropPoint.ip) { + PlacesControllerDragHelper.onDrop( + dropPoint.ip, + event.dataTransfer + ).catch(console.error); + event.preventDefault(); + } + + this._cleanupDragDetails(); + event.stopPropagation(); + } + + on_dragover(event) { + PlacesControllerDragHelper.currentDropTarget = event.target; + let dt = event.dataTransfer; + + let dropPoint = this._getDropPoint(event); + if ( + !dropPoint || + !dropPoint.ip || + !PlacesControllerDragHelper.canDrop(dropPoint.ip, dt) + ) { + this._indicatorBar.hidden = true; + event.stopPropagation(); + return; + } + + // Mark this popup as being dragged over. + this.setAttribute("dragover", "true"); + + if (dropPoint.folderElt) { + // We are dragging over a folder. + // _overFolder should take the care of opening it on a timer. + if ( + this._overFolder.elt && + this._overFolder.elt != dropPoint.folderElt + ) { + // We are dragging over a new folder, let's clear old values + this._overFolder.clear(); + } + if (!this._overFolder.elt) { + this._overFolder.elt = dropPoint.folderElt; + // Create the timer to open this folder. + this._overFolder.openTimer = this._overFolder.setTimer( + this._overFolder.hoverTime + ); + } + // Since we are dropping into a folder set the corresponding style. + dropPoint.folderElt.setAttribute("_moz-menuactive", true); + } else { + // We are not dragging over a folder. + // Clear out old _overFolder information. + this._overFolder.clear(); + } + + // Autoscroll the popup strip if we drag over the scroll buttons. + let scrollDir = 0; + if (event.originalTarget == this.scrollBox._scrollButtonUp) { + scrollDir = -1; + } else if (event.originalTarget == this.scrollBox._scrollButtonDown) { + scrollDir = 1; + } + if (scrollDir != 0) { + this.scrollBox.scrollByIndex(scrollDir, true); + } + + // Check if we should hide the drop indicator for this target. + if (dropPoint.folderElt || this._hideDropIndicator(event)) { + this._indicatorBar.hidden = true; + event.preventDefault(); + event.stopPropagation(); + return; + } + + // We should display the drop indicator relative to the arrowscrollbox. + let scrollRect = this.scrollBox.getBoundingClientRect(); + let newMarginTop = 0; + if (scrollDir == 0) { + let elt = this.firstElementChild; + for (; elt; elt = elt.nextElementSibling) { + let height = elt.getBoundingClientRect().height; + if (height == 0) { + continue; + } + if (event.screenY <= elt.screenY + height / 2) { + break; + } + } + newMarginTop = elt + ? elt.screenY - this.scrollBox.screenY + : scrollRect.height; + } else if (scrollDir == 1) { + newMarginTop = scrollRect.height; + } + + // Set the new marginTop based on arrowscrollbox. + newMarginTop += + scrollRect.y - this._indicatorBar.parentNode.getBoundingClientRect().y; + this._indicatorBar.firstElementChild.style.marginTop = + newMarginTop + "px"; + this._indicatorBar.hidden = false; + + event.preventDefault(); + event.stopPropagation(); + } + + on_dragleave(event) { + PlacesControllerDragHelper.currentDropTarget = null; + this.removeAttribute("dragover"); + + // If we have not moved to a valid new target clear the drop indicator + // this happens when moving out of the popup. + let target = event.relatedTarget; + if (!target || !this.contains(target)) { + this._indicatorBar.hidden = true; + } + + // Close any folder being hovered over + if (this._overFolder.elt) { + this._overFolder.closeTimer = this._overFolder.setTimer( + this._overFolder.hoverTime + ); + } + + // The autoopened attribute is set when this folder was automatically + // opened after the user dragged over it. If this attribute is set, + // auto-close the folder on drag exit. + // We should also try to close this popup if the drag has started + // from here, the timer will check if we are dragging over a child. + if (this.hasAttribute("autoopened") || this.hasAttribute("dragstart")) { + this._overFolder.closeMenuTimer = this._overFolder.setTimer( + this._overFolder.hoverTime + ); + } + + event.stopPropagation(); + } + + on_dragend(event) { + this._cleanupDragDetails(); + } + } + + customElements.define("places-popup", MozPlacesPopup, { + extends: "menupopup", + }); + + /** + * Custom element for the places popup arrow. + */ + class MozPlacesPopupArrow extends MozPlacesPopup { + constructor() { + super(); + + const event_names = [ + "popupshowing", + "popuppositioned", + "popupshown", + "popuphiding", + "popuphidden", + ]; + for (let event_name of event_names) { + this.addEventListener(event_name, this); + } + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + super.connectedCallback(); + this.initializeAttributeInheritance(); + + this.setAttribute("flip", "both"); + this.setAttribute("side", "top"); + this.setAttribute("position", "bottomright topright"); + } + + _setSideAttribute(event) { + if (!this.anchorNode) { + return; + } + + var position = event.alignmentPosition; + if (position.indexOf("start_") == 0 || position.indexOf("end_") == 0) { + // The assigned side stays the same regardless of direction. + let isRTL = this.matches(":-moz-locale-dir(rtl)"); + + if (position.indexOf("start_") == 0) { + this.setAttribute("side", isRTL ? "left" : "right"); + } else { + this.setAttribute("side", isRTL ? "right" : "left"); + } + } else if ( + position.indexOf("before_") == 0 || + position.indexOf("after_") == 0 + ) { + if (position.indexOf("before_") == 0) { + this.setAttribute("side", "bottom"); + } else { + this.setAttribute("side", "top"); + } + } + } + + on_popupshowing(event) { + if (event.target == this) { + this.setAttribute("animate", "open"); + this.style.pointerEvents = "none"; + } + } + + on_popuppositioned(event) { + if (event.target == this) { + this._setSideAttribute(event); + } + } + + on_popupshown(event) { + if (event.target != this) { + return; + } + + this.setAttribute("panelopen", "true"); + this.style.removeProperty("pointer-events"); + } + + on_popuphiding(event) { + if (event.target == this) { + this.setAttribute("animate", "cancel"); + } + } + + on_popuphidden(event) { + if (event.target == this) { + this.removeAttribute("panelopen"); + this.removeAttribute("animate"); + } + } + } + + customElements.define("places-popup-arrow", MozPlacesPopupArrow, { + extends: "menupopup", + }); +} diff --git a/browser/components/places/content/places-tree.js b/browser/components/places/content/places-tree.js new file mode 100644 index 0000000000..08437b5e08 --- /dev/null +++ b/browser/components/places/content/places-tree.js @@ -0,0 +1,865 @@ +/* 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; + } + + 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: + // + // 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() { + 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", + }); +} diff --git a/browser/components/places/content/places.css b/browser/components/places/content/places.css new file mode 100644 index 0000000000..0021e28bb6 --- /dev/null +++ b/browser/components/places/content/places.css @@ -0,0 +1,43 @@ +/* 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/. */ + +:root { + /* we eventually want to share this value with the bookmark panel, which is + currently using --arrowpanel-padding */ + --editbookmarkdialog-padding: 1.25em; +} + +tree[is="places-tree"] > treechildren::-moz-tree-cell { + /* ensure we use the direction of the website title / url instead of the + * browser locale */ + unicode-bidi: plaintext; +} + +.places-tooltip-title { + /* ensure we use the direction of the website title instead of the + * browser locale */ + unicode-bidi: plaintext; +} + +.toolbar-drop-indicator { + position: relative; + z-index: 1; +} + +/* Apply crisp rendering for favicons at exactly 2dppx resolution */ +@media (resolution: 2dppx) { + #bookmarksChildren, + .sidebar-placesTreechildren, + .placesTree > treechildren { + image-rendering: -moz-crisp-edges; + } +} + +#searchFilter { + max-width: 23em; +} + +.places-tooltip-box { + display: block; +} diff --git a/browser/components/places/content/places.js b/browser/components/places/content/places.js new file mode 100644 index 0000000000..b58cee70d5 --- /dev/null +++ b/browser/components/places/content/places.js @@ -0,0 +1,1526 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* 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 editBookmark.js */ +/* import-globals-from /toolkit/content/contentAreaUtils.js */ +/* import-globals-from /browser/components/downloads/content/allDownloadsView.js */ + +/* Shared Places Import - change other consumers if you change this: */ +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +ChromeUtils.defineESModuleGetters(this, { + BookmarkJSONUtils: "resource://gre/modules/BookmarkJSONUtils.sys.mjs", + MigrationUtils: "resource:///modules/MigrationUtils.sys.mjs", + PlacesBackups: "resource://gre/modules/PlacesBackups.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + DownloadUtils: "resource://gre/modules/DownloadUtils.sys.mjs", +}); +XPCOMUtils.defineLazyScriptGetter( + this, + "PlacesTreeView", + "chrome://browser/content/places/treeView.js" +); +XPCOMUtils.defineLazyScriptGetter( + this, + ["PlacesInsertionPoint", "PlacesController", "PlacesControllerDragHelper"], + "chrome://browser/content/places/controller.js" +); +/* End Shared Places Import */ + +var { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +const RESTORE_FILEPICKER_FILTER_EXT = "*.json;*.jsonlz4"; +const HISTORY_LIBRARY_SEARCH_TELEMETRY = + "PLACES_HISTORY_LIBRARY_SEARCH_TIME_MS"; + +const SORTBY_L10N_IDS = new Map([ + ["title", "places-view-sortby-name"], + ["url", "places-view-sortby-url"], + ["date", "places-view-sortby-date"], + ["visitCount", "places-view-sortby-visit-count"], + ["dateAdded", "places-view-sortby-date-added"], + ["lastModified", "places-view-sortby-last-modified"], + ["tags", "places-view-sortby-tags"], +]); + +var PlacesOrganizer = { + _places: null, + + _initFolderTree() { + this._places.place = `place:type=${Ci.nsINavHistoryQueryOptions.RESULTS_AS_LEFT_PANE_QUERY}&excludeItems=1&expandQueries=0`; + }, + + /** + * Selects a left pane built-in item. + * + * @param {string} item The built-in item to select, may be one of (case sensitive): + * AllBookmarks, BookmarksMenu, BookmarksToolbar, + * History, Downloads, Tags, UnfiledBookmarks. + */ + selectLeftPaneBuiltIn(item) { + switch (item) { + case "AllBookmarks": + this._places.selectItems([PlacesUtils.virtualAllBookmarksGuid]); + PlacesUtils.asContainer(this._places.selectedNode).containerOpen = true; + break; + case "History": + this._places.selectItems([PlacesUtils.virtualHistoryGuid]); + PlacesUtils.asContainer(this._places.selectedNode).containerOpen = true; + break; + case "Downloads": + this._places.selectItems([PlacesUtils.virtualDownloadsGuid]); + break; + case "Tags": + this._places.selectItems([PlacesUtils.virtualTagsGuid]); + break; + case "BookmarksMenu": + this.selectLeftPaneContainerByHierarchy([ + PlacesUtils.virtualAllBookmarksGuid, + PlacesUtils.bookmarks.virtualMenuGuid, + ]); + break; + case "BookmarksToolbar": + this.selectLeftPaneContainerByHierarchy([ + PlacesUtils.virtualAllBookmarksGuid, + PlacesUtils.bookmarks.virtualToolbarGuid, + ]); + break; + case "UnfiledBookmarks": + this.selectLeftPaneContainerByHierarchy([ + PlacesUtils.virtualAllBookmarksGuid, + PlacesUtils.bookmarks.virtualUnfiledGuid, + ]); + break; + default: + throw new Error( + `Unrecognized item ${item} passed to selectLeftPaneRootItem` + ); + } + }, + + /** + * Opens a given hierarchy in the left pane, stopping at the last reachable + * container. Note: item ids should be considered deprecated. + * + * @param {Array | string | number} aHierarchy + * A single container or an array of containers, sorted from + * the outmost to the innermost in the hierarchy. Each + * container may be either an item id, a Places URI string, + * or a named query, like: + * "BookmarksMenu", "BookmarksToolbar", "UnfiledBookmarks", "AllBookmarks". + */ + selectLeftPaneContainerByHierarchy(aHierarchy) { + if (!aHierarchy) { + throw new Error("Containers hierarchy not specified"); + } + let hierarchy = [].concat(aHierarchy); + let selectWasSuppressed = + this._places.view.selection.selectEventsSuppressed; + if (!selectWasSuppressed) { + this._places.view.selection.selectEventsSuppressed = true; + } + try { + for (let container of hierarchy) { + if (typeof container != "string") { + throw new Error("Invalid container type found: " + container); + } + + try { + this.selectLeftPaneBuiltIn(container); + } catch (ex) { + if (container.substr(0, 6) == "place:") { + this._places.selectPlaceURI(container); + } else { + // Must be a guid. + this._places.selectItems([container], false); + } + } + PlacesUtils.asContainer(this._places.selectedNode).containerOpen = true; + } + } finally { + if (!selectWasSuppressed) { + this._places.view.selection.selectEventsSuppressed = false; + } + } + }, + + init: function PO_init() { + // Register the downloads view. + const DOWNLOADS_QUERY = + "place:transition=" + + Ci.nsINavHistoryService.TRANSITION_DOWNLOAD + + "&sort=" + + Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING; + + ContentArea.setContentViewForQueryString( + DOWNLOADS_QUERY, + () => + new DownloadsPlacesView( + document.getElementById("downloadsListBox"), + false + ), + { + showDetailsPane: false, + toolbarSet: + "back-button, forward-button, organizeButton, clearDownloadsButton, libraryToolbarSpacer, searchFilter", + } + ); + + ContentArea.init(); + + this._places = document.getElementById("placesList"); + this._initFolderTree(); + + var leftPaneSelection = "AllBookmarks"; // default to all-bookmarks + if (window.arguments && window.arguments[0]) { + leftPaneSelection = window.arguments[0]; + } + + this.selectLeftPaneContainerByHierarchy(leftPaneSelection); + if (leftPaneSelection === "History") { + let historyNode = this._places.selectedNode; + if (historyNode.childCount > 0) { + this._places.selectNode(historyNode.getChild(0)); + } + Services.telemetry.keyedScalarAdd("library.opened", "history", 1); + } else { + Services.telemetry.keyedScalarAdd("library.opened", "bookmarks", 1); + } + + // clear the back-stack + this._backHistory.splice(0, this._backHistory.length); + document + .getElementById("OrganizerCommand:Back") + .setAttribute("disabled", true); + + // Set up the search UI. + PlacesSearchBox.init(); + + window.addEventListener("AppCommand", this, true); + + let placeContentElement = document.getElementById("placeContent"); + placeContentElement.addEventListener("onOpenFlatContainer", function (e) { + PlacesOrganizer.openFlatContainer(e.detail); + }); + + if (AppConstants.platform === "macosx") { + // 1. Map Edit->Find command to OrganizerCommand_find:all. Need to map + // both the menuitem and the Find key. + let findMenuItem = document.getElementById("menu_find"); + findMenuItem.setAttribute("command", "OrganizerCommand_find:all"); + let findKey = document.getElementById("key_find"); + findKey.setAttribute("command", "OrganizerCommand_find:all"); + + // 2. Disable some keybindings from browser.xhtml + let elements = ["cmd_handleBackspace", "cmd_handleShiftBackspace"]; + for (let i = 0; i < elements.length; i++) { + document.getElementById(elements[i]).setAttribute("disabled", "true"); + } + } + + // remove the "Edit" and "Edit Bookmark" context-menu item, we're in our own details pane + let contextMenu = document.getElementById("placesContext"); + contextMenu.removeChild(document.getElementById("placesContext_show:info")); + contextMenu.removeChild( + document.getElementById("placesContext_show_bookmark:info") + ); + contextMenu.removeChild( + document.getElementById("placesContext_show_folder:info") + ); + + if (!Services.policies.isAllowed("profileImport")) { + document + .getElementById("OrganizerCommand_browserImport") + .setAttribute("disabled", true); + } + + ContentArea.focus(); + }, + + QueryInterface: ChromeUtils.generateQI([]), + + handleEvent: function PO_handleEvent(aEvent) { + if (aEvent.type != "AppCommand") { + return; + } + + aEvent.stopPropagation(); + switch (aEvent.command) { + case "Back": + if (this._backHistory.length) { + this.back(); + } + break; + case "Forward": + if (this._forwardHistory.length) { + this.forward(); + } + break; + case "Search": + PlacesSearchBox.findAll(); + break; + } + }, + + destroy: function PO_destroy() {}, + + _location: null, + get location() { + return this._location; + }, + + set location(aLocation) { + if (!aLocation || this._location == aLocation) { + return; + } + + if (this.location) { + this._backHistory.unshift(this.location); + this._forwardHistory.splice(0, this._forwardHistory.length); + } + + this._location = aLocation; + this._places.selectPlaceURI(aLocation); + + if (!this._places.hasSelection) { + // If no node was found for the given place: uri, just load it directly + ContentArea.currentPlace = aLocation; + } + this.updateDetailsPane(); + + // update navigation commands + if (!this._backHistory.length) { + document + .getElementById("OrganizerCommand:Back") + .setAttribute("disabled", true); + } else { + document + .getElementById("OrganizerCommand:Back") + .removeAttribute("disabled"); + } + if (!this._forwardHistory.length) { + document + .getElementById("OrganizerCommand:Forward") + .setAttribute("disabled", true); + } else { + document + .getElementById("OrganizerCommand:Forward") + .removeAttribute("disabled"); + } + }, + + _backHistory: [], + _forwardHistory: [], + + back: function PO_back() { + this._forwardHistory.unshift(this.location); + var historyEntry = this._backHistory.shift(); + this._location = null; + this.location = historyEntry; + }, + forward: function PO_forward() { + this._backHistory.unshift(this.location); + var historyEntry = this._forwardHistory.shift(); + this._location = null; + this.location = historyEntry; + }, + + /** + * Called when a place folder is selected in the left pane. + * + * @param resetSearchBox + * true if the search box should also be reset, false otherwise. + * The search box should be reset when a new folder in the left + * pane is selected; the search scope and text need to be cleared in + * preparation for the new folder. Note that if the user manually + * resets the search box, either by clicking its reset button or by + * deleting its text, this will be false. + */ + _cachedLeftPaneSelectedURI: null, + onPlaceSelected: function PO_onPlaceSelected(resetSearchBox) { + // Don't change the right-hand pane contents when there's no selection. + if (!this._places.hasSelection) { + return; + } + + let node = this._places.selectedNode; + let placeURI = node.uri; + + // If either the place of the content tree in the right pane has changed or + // the user cleared the search box, update the place, hide the search UI, + // and update the back/forward buttons by setting location. + if (ContentArea.currentPlace != placeURI || !resetSearchBox) { + ContentArea.currentPlace = placeURI; + this.location = placeURI; + } + + // When we invalidate a container we use suppressSelectionEvent, when it is + // unset a select event is fired, in many cases the selection did not really + // change, so we should check for it, and return early in such a case. Note + // that we cannot return any earlier than this point, because when + // !resetSearchBox, we need to update location and hide the UI as above, + // even though the selection has not changed. + if (placeURI == this._cachedLeftPaneSelectedURI) { + return; + } + this._cachedLeftPaneSelectedURI = placeURI; + + // At this point, resetSearchBox is true, because the left pane selection + // has changed; otherwise we would have returned earlier. + + let input = PlacesSearchBox.searchFilter; + input.value = ""; + input.editor?.clearUndoRedo(); + this._setSearchScopeForNode(node); + this.updateDetailsPane(); + }, + + /** + * Sets the search scope based on aNode's properties. + * + * @param {object} aNode + * the node to set up scope from + */ + _setSearchScopeForNode: function PO__setScopeForNode(aNode) { + let itemGuid = aNode.bookmarkGuid; + + if ( + PlacesUtils.nodeIsHistoryContainer(aNode) || + itemGuid == PlacesUtils.virtualHistoryGuid + ) { + PlacesQueryBuilder.setScope("history"); + } else if (itemGuid == PlacesUtils.virtualDownloadsGuid) { + PlacesQueryBuilder.setScope("downloads"); + } else { + // Default to All Bookmarks for all other nodes, per bug 469437. + PlacesQueryBuilder.setScope("bookmarks"); + } + }, + + /** + * Handle clicks on the places list. + * Single Left click, right click or modified click do not result in any + * special action, since they're related to selection. + * + * @param {object} aEvent + * The mouse event. + */ + onPlacesListClick: function PO_onPlacesListClick(aEvent) { + // Only handle clicks on tree children. + if (aEvent.target.localName != "treechildren") { + return; + } + + let node = this._places.selectedNode; + if (node) { + let middleClick = aEvent.button == 1 && aEvent.detail == 1; + if (middleClick && PlacesUtils.nodeIsContainer(node)) { + // The command execution function will take care of seeing if the + // selection is a folder or a different container type, and will + // load its contents in tabs. + PlacesUIUtils.openMultipleLinksInTabs(node, aEvent, this._places); + } + } + }, + + /** + * Handle focus changes on the places list and the current content view. + */ + updateDetailsPane: function PO_updateDetailsPane() { + if (!ContentArea.currentViewOptions.showDetailsPane) { + return; + } + // _fillDetailsPane is only invoked when the activeElement is a tree, + // there's no other case where we need to update the details pane. This + // means it's not possible that while some input field in the panel is + // focused we try to update the panel contents causing potential dataloss + // of the user's input. + let view = PlacesUIUtils.getViewForNode(document.activeElement); + if (view) { + let selectedNodes = view.selectedNode + ? [view.selectedNode] + : view.selectedNodes; + this._fillDetailsPane(selectedNodes); + } + }, + + /** + * Handle openFlatContainer events. + * + * @param {object} aContainer + * The node the event was dispatched on. + */ + openFlatContainer(aContainer) { + if (aContainer.bookmarkGuid) { + PlacesUtils.asContainer(this._places.selectedNode).containerOpen = true; + this._places.selectItems([aContainer.bookmarkGuid], false); + } else if (PlacesUtils.nodeIsQuery(aContainer)) { + this._places.selectPlaceURI(aContainer.uri); + } + }, + + /** + * @returns {object} + * Returns the options associated with the query currently loaded in the + * main places pane. + */ + getCurrentOptions: function PO_getCurrentOptions() { + return PlacesUtils.asQuery(ContentArea.currentView.result.root) + .queryOptions; + }, + + /** + * Show the migration wizard for importing passwords, + * cookies, history, preferences, and bookmarks. + */ + importFromBrowser: function PO_importFromBrowser() { + // We pass in the type of source we're using for use in telemetry: + MigrationUtils.showMigrationWizard(window, { + entrypoint: MigrationUtils.MIGRATION_ENTRYPOINTS.PLACES, + }); + }, + + /** + * Open a file-picker and import the selected file into the bookmarks store + */ + importFromFile: function PO_importFromFile() { + let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + let fpCallback = function fpCallback_done(aResult) { + if (aResult != Ci.nsIFilePicker.returnCancel && fp.fileURL) { + var { BookmarkHTMLUtils } = ChromeUtils.importESModule( + "resource://gre/modules/BookmarkHTMLUtils.sys.mjs" + ); + BookmarkHTMLUtils.importFromURL(fp.fileURL.spec).catch(console.error); + } + }; + + fp.init( + window, + PlacesUIUtils.promptLocalization.formatValueSync( + "places-bookmarks-import" + ), + Ci.nsIFilePicker.modeOpen + ); + fp.appendFilters(Ci.nsIFilePicker.filterHTML); + fp.open(fpCallback); + }, + + /** + * Allows simple exporting of bookmarks. + */ + exportBookmarks: function PO_exportBookmarks() { + let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + let fpCallback = function fpCallback_done(aResult) { + if (aResult != Ci.nsIFilePicker.returnCancel) { + var { BookmarkHTMLUtils } = ChromeUtils.importESModule( + "resource://gre/modules/BookmarkHTMLUtils.sys.mjs" + ); + BookmarkHTMLUtils.exportToFile(fp.file.path).catch(console.error); + } + }; + + fp.init( + window, + PlacesUIUtils.promptLocalization.formatValueSync( + "places-bookmarks-export" + ), + Ci.nsIFilePicker.modeSave + ); + fp.appendFilters(Ci.nsIFilePicker.filterHTML); + fp.defaultString = "bookmarks.html"; + fp.open(fpCallback); + }, + + /** + * Populates the restore menu with the dates of the backups available. + */ + populateRestoreMenu: function PO_populateRestoreMenu() { + let restorePopup = document.getElementById("fileRestorePopup"); + + const dtOptions = { + dateStyle: "long", + }; + let dateFormatter = new Services.intl.DateTimeFormat(undefined, dtOptions); + + // Remove existing menu items. Last item is the restoreFromFile item. + while (restorePopup.childNodes.length > 1) { + restorePopup.firstChild.remove(); + } + + (async function () { + let backupFiles = await PlacesBackups.getBackupFiles(); + if (!backupFiles.length) { + return; + } + + // Populate menu with backups. + for (let file of backupFiles) { + let fileSize = (await IOUtils.stat(file)).size; + let [size, unit] = DownloadUtils.convertByteUnits(fileSize); + let sizeString = PlacesUtils.getFormattedString("backupFileSizeText", [ + size, + unit, + ]); + + let countString; + let count = PlacesBackups.getBookmarkCountForFile(file); + if (count != null) { + const [msg] = await document.l10n.formatMessages([ + { id: "places-details-pane-items-count", args: { count } }, + ]); + countString = msg.attributes.find( + attr => attr.name === "value" + )?.value; + } + + const backupDate = PlacesBackups.getDateForFile(file); + let label = dateFormatter.format(backupDate); + label += countString + ? ` (${sizeString} - ${countString})` + : ` (${sizeString})`; + + let m = restorePopup.insertBefore( + document.createXULElement("menuitem"), + document.getElementById("restoreFromFile") + ); + m.setAttribute("label", label); + m.setAttribute("value", PathUtils.filename(file)); + m.setAttribute( + "oncommand", + "PlacesOrganizer.onRestoreMenuItemClick(this);" + ); + } + + // Add the restoreFromFile item. + restorePopup.insertBefore( + document.createXULElement("menuseparator"), + document.getElementById("restoreFromFile") + ); + })(); + }, + + /** + * Called when a menuitem is selected from the restore menu. + * + * @param {object} aMenuItem The menuitem that was selected. + */ + async onRestoreMenuItemClick(aMenuItem) { + let backupName = aMenuItem.getAttribute("value"); + let backupFilePaths = await PlacesBackups.getBackupFiles(); + for (let backupFilePath of backupFilePaths) { + if (PathUtils.filename(backupFilePath) == backupName) { + PlacesOrganizer.restoreBookmarksFromFile(backupFilePath); + break; + } + } + }, + + /** + * Called when 'Choose File...' is selected from the restore menu. + * Prompts for a file and restores bookmarks to those in the file. + */ + onRestoreBookmarksFromFile: function PO_onRestoreBookmarksFromFile() { + let backupsDir = Services.dirsvc.get("Desk", Ci.nsIFile); + let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + let fpCallback = aResult => { + if (aResult != Ci.nsIFilePicker.returnCancel) { + this.restoreBookmarksFromFile(fp.file.path); + } + }; + + const [title, filterName] = + PlacesUIUtils.promptLocalization.formatValuesSync([ + "places-bookmarks-restore-title", + "places-bookmarks-restore-filter-name", + ]); + fp.init(window, title, Ci.nsIFilePicker.modeOpen); + fp.appendFilter(filterName, RESTORE_FILEPICKER_FILTER_EXT); + fp.appendFilters(Ci.nsIFilePicker.filterAll); + fp.displayDirectory = backupsDir; + fp.open(fpCallback); + }, + + /** + * Restores bookmarks from a JSON file. + * + * @param {string} aFilePath + * The path of the file to restore from. + */ + restoreBookmarksFromFile: function PO_restoreBookmarksFromFile(aFilePath) { + // check file extension + if ( + !aFilePath.toLowerCase().endsWith("json") && + !aFilePath.toLowerCase().endsWith("jsonlz4") + ) { + this._showErrorAlert("places-bookmarks-restore-format-error"); + return; + } + + const [title, body] = PlacesUIUtils.promptLocalization.formatValuesSync([ + "places-bookmarks-restore-alert-title", + "places-bookmarks-restore-alert", + ]); + // confirm ok to delete existing bookmarks + if (!Services.prompt.confirm(null, title, body)) { + return; + } + + (async function () { + try { + await BookmarkJSONUtils.importFromFile(aFilePath, { + replace: true, + }); + } catch (ex) { + PlacesOrganizer._showErrorAlert("places-bookmarks-restore-parse-error"); + } + })(); + }, + + _showErrorAlert: function PO__showErrorAlert(l10nId) { + const [title, msg] = PlacesUIUtils.promptLocalization.formatValuesSync([ + "places-error-title", + l10nId, + ]); + Services.prompt.alert(window, title, msg); + }, + + /** + * Backup bookmarks to desktop, auto-generate a filename with a date. + * The file is a JSON serialization of bookmarks, tags and any annotations + * of those items. + */ + backupBookmarks: function PO_backupBookmarks() { + let backupsDir = Services.dirsvc.get("Desk", Ci.nsIFile); + let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + let fpCallback = function fpCallback_done(aResult) { + if (aResult != Ci.nsIFilePicker.returnCancel) { + // There is no OS.File version of the filepicker yet (Bug 937812). + PlacesBackups.saveBookmarksToJSONFile(fp.file.path).catch( + console.error + ); + } + }; + + const [title, filterName] = + PlacesUIUtils.promptLocalization.formatValuesSync([ + "places-bookmarks-backup-title", + "places-bookmarks-restore-filter-name", + ]); + fp.init(window, title, Ci.nsIFilePicker.modeSave); + fp.appendFilter(filterName, RESTORE_FILEPICKER_FILTER_EXT); + fp.defaultString = PlacesBackups.getFilenameForDate(); + fp.defaultExtension = "json"; + fp.displayDirectory = backupsDir; + fp.open(fpCallback); + }, + + _fillDetailsPane: function PO__fillDetailsPane(aNodeList) { + var infoBox = document.getElementById("infoBox"); + var itemsCountBox = document.getElementById("itemsCountBox"); + + // Make sure the infoBox UI is visible if we need to use it, we hide it + // below when we don't. + infoBox.hidden = false; + itemsCountBox.hidden = true; + + let selectedNode = aNodeList.length == 1 ? aNodeList[0] : null; + + // Don't update the panel if it's already editing this node, unless we're + // in multi-edit mode. + if ( + selectedNode && + !gEditItemOverlay.multiEdit && + ((gEditItemOverlay.concreteGuid && + gEditItemOverlay.concreteGuid == + PlacesUtils.getConcreteItemGuid(selectedNode)) || + (!selectedNode.bookmarkGuid && + gEditItemOverlay.uri && + gEditItemOverlay.uri == selectedNode.uri)) + ) { + return; + } + + // Clean up the panel before initing it again. + gEditItemOverlay.uninitPanel(false); + + if (selectedNode && !PlacesUtils.nodeIsSeparator(selectedNode)) { + gEditItemOverlay + .initPanel({ + node: selectedNode, + hiddenRows: ["folderPicker"], + }) + .catch(ex => console.error(ex)); + } else if (!selectedNode && aNodeList[0]) { + if (aNodeList.every(PlacesUtils.nodeIsURI)) { + let uris = aNodeList.map(node => Services.io.newURI(node.uri)); + gEditItemOverlay + .initPanel({ + uris, + hiddenRows: ["folderPicker", "location", "keyword", "name"], + }) + .catch(ex => console.error(ex)); + } else { + let selectItemDesc = document.getElementById("selectItemDescription"); + let itemsCountLabel = document.getElementById("itemsCountText"); + selectItemDesc.hidden = false; + document.l10n.setAttributes( + itemsCountLabel, + "places-details-pane-items-count", + { count: aNodeList.length } + ); + infoBox.hidden = true; + } + } else { + infoBox.hidden = true; + let selectItemDesc = document.getElementById("selectItemDescription"); + let itemsCountLabel = document.getElementById("itemsCountText"); + let itemsCount = 0; + if (ContentArea.currentView.result) { + let rootNode = ContentArea.currentView.result.root; + if (rootNode.containerOpen) { + itemsCount = rootNode.childCount; + } + } + if (itemsCount == 0) { + selectItemDesc.hidden = true; + document.l10n.setAttributes( + itemsCountLabel, + "places-details-pane-no-items" + ); + } else { + selectItemDesc.hidden = false; + document.l10n.setAttributes( + itemsCountLabel, + "places-details-pane-items-count", + { count: itemsCount } + ); + } + } + itemsCountBox.hidden = !infoBox.hidden; + }, +}; + +/** + * A set of utilities relating to search within Bookmarks and History. + */ +var PlacesSearchBox = { + /** + * The Search text field + * + * @see {@link https://searchfox.org/mozilla-central/source/toolkit/content/widgets/search-textbox.js} + * @returns {HTMLInputElement} + */ + get searchFilter() { + return document.getElementById("searchFilter"); + }, + + cumulativeHistorySearches: 0, + cumulativeBookmarkSearches: 0, + + /** + * Folders to include when searching. + */ + _folders: [], + get folders() { + if (!this._folders.length) { + this._folders = PlacesUtils.bookmarks.userContentRoots; + } + return this._folders; + }, + set folders(aFolders) { + this._folders = aFolders; + }, + + /** + * Run a search for the specified text, over the collection specified by + * the dropdown arrow. The default is all bookmarks, but can be + * localized to the active collection. + * + * @param {string} filterString + * The text to search for. + */ + search(filterString) { + var PO = PlacesOrganizer; + // If the user empties the search box manually, reset it and load all + // contents of the current scope. + // XXX this might be to jumpy, maybe should search for "", so results + // are ungrouped, and search box not reset + if (filterString == "") { + PO.onPlaceSelected(false); + return; + } + + let currentView = ContentArea.currentView; + + // Search according to the current scope, which was set by + // PQB_setScope() + switch (PlacesSearchBox.filterCollection) { + case "bookmarks": + currentView.applyFilter(filterString, this.folders); + Services.telemetry.keyedScalarAdd("library.search", "bookmarks", 1); + this.cumulativeBookmarkSearches++; + break; + case "history": { + let currentOptions = PO.getCurrentOptions(); + if ( + currentOptions.queryType != + Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY + ) { + let query = PlacesUtils.history.getNewQuery(); + query.searchTerms = filterString; + let options = currentOptions.clone(); + // Make sure we're getting uri results. + options.resultType = currentOptions.RESULTS_AS_URI; + options.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY; + options.includeHidden = true; + currentView.load([query], options); + } else { + TelemetryStopwatch.start(HISTORY_LIBRARY_SEARCH_TELEMETRY); + currentView.applyFilter(filterString, null, true); + TelemetryStopwatch.finish(HISTORY_LIBRARY_SEARCH_TELEMETRY); + Services.telemetry.keyedScalarAdd("library.search", "history", 1); + this.cumulativeHistorySearches++; + } + break; + } + case "downloads": { + // The new downloads view doesn't use places for searching downloads. + currentView.searchTerm = filterString; + break; + } + default: + throw new Error("Invalid filterCollection on search"); + } + + // Update the details panel + PlacesOrganizer.updateDetailsPane(); + }, + + /** + * Finds across all history, downloads or all bookmarks. + */ + findAll() { + switch (this.filterCollection) { + case "history": + PlacesQueryBuilder.setScope("history"); + break; + case "downloads": + PlacesQueryBuilder.setScope("downloads"); + break; + default: + PlacesQueryBuilder.setScope("bookmarks"); + break; + } + this.focus(); + }, + + /** + * Updates the search input placeholder to match the current collection. + */ + updatePlaceholder() { + let l10nId = ""; + switch (this.filterCollection) { + case "history": + l10nId = "places-search-history"; + break; + case "downloads": + l10nId = "places-search-downloads"; + break; + default: + l10nId = "places-search-bookmarks"; + } + document.l10n.setAttributes(this.searchFilter, l10nId); + }, + + /** + * Gets/sets the active collection from the dropdown menu. + * + * @returns {string} + */ + get filterCollection() { + return this.searchFilter.getAttribute("collection"); + }, + set filterCollection(collectionName) { + if (collectionName == this.filterCollection) { + return; + } + + this.searchFilter.setAttribute("collection", collectionName); + this.updatePlaceholder(); + }, + + /** + * Focus the search box + */ + focus() { + this.searchFilter.focus(); + }, + + /** + * Set up the gray text in the search bar as the Places View loads. + */ + init() { + this.updatePlaceholder(); + }, + + /** + * Gets or sets the text shown in the Places Search Box + * + * @returns {string} + */ + get value() { + return this.searchFilter.value; + }, + set value(value) { + this.searchFilter.value = value; + }, +}; + +function updateTelemetry(urlsOpened) { + let historyLinks = urlsOpened.filter( + link => !link.isBookmark && !PlacesUtils.nodeIsBookmark(link) + ); + if (!historyLinks.length) { + let searchesHistogram = Services.telemetry.getHistogramById( + "PLACES_LIBRARY_CUMULATIVE_BOOKMARK_SEARCHES" + ); + searchesHistogram.add(PlacesSearchBox.cumulativeBookmarkSearches); + + // Clear cumulative search counter + PlacesSearchBox.cumulativeBookmarkSearches = 0; + + Services.telemetry.keyedScalarAdd( + "library.link", + "bookmarks", + urlsOpened.length + ); + return; + } + + // Record cumulative search count before selecting History link from Library + let searchesHistogram = Services.telemetry.getHistogramById( + "PLACES_LIBRARY_CUMULATIVE_HISTORY_SEARCHES" + ); + searchesHistogram.add(PlacesSearchBox.cumulativeHistorySearches); + + // Clear cumulative search counter + PlacesSearchBox.cumulativeHistorySearches = 0; + + Services.telemetry.keyedScalarAdd( + "library.link", + "history", + historyLinks.length + ); +} + +/** + * Functions and data for advanced query builder + */ +var PlacesQueryBuilder = { + queries: [], + queryOptions: null, + + /** + * Sets the search scope. This can be called when no search is active, and + * in that case, when `search()` is called, `aScope` will be used. + * If there is an active search, it's performed again to + * update the content tree. + * + * @param {"bookmarks" | "downloads" | "history"} aScope + * The search scope: "bookmarks", "downloads" or "history". + */ + setScope(aScope) { + // Determine filterCollection, folders, and scopeButtonId based on aScope. + var filterCollection; + var folders = []; + switch (aScope) { + case "history": + filterCollection = "history"; + break; + case "bookmarks": + filterCollection = "bookmarks"; + folders = PlacesUtils.bookmarks.userContentRoots; + break; + case "downloads": + filterCollection = "downloads"; + break; + default: + throw new Error("Invalid search scope"); + } + + // Update the search box. Re-search if there's an active search. + PlacesSearchBox.filterCollection = filterCollection; + PlacesSearchBox.folders = folders; + var searchStr = PlacesSearchBox.searchFilter.value; + if (searchStr) { + PlacesSearchBox.search(searchStr); + } + }, +}; + +/** + * Population and commands for the View Menu. + */ +var ViewMenu = { + /** + * Removes content generated previously from a menupopup. + * + * @param {object} popup + * The popup that contains the previously generated content. + * @param {string} startID + * The id attribute of an element that is the start of the + * dynamically generated region - remove elements after this + * item only. + * Must be contained by popup. Can be null (in which case the + * contents of popup are removed). + * @param {string} endID + * The id attribute of an element that is the end of the + * dynamically generated region - remove elements up to this + * item only. + * Must be contained by popup. Can be null (in which case all + * items until the end of the popup will be removed). Ignored + * if startID is null. + * @returns {object|null} The element for the caller to insert new items before, + * null if the caller should just append to the popup. + */ + _clean: function VM__clean(popup, startID, endID) { + if (endID && !startID) { + throw new Error("meaningless to have valid endID and null startID"); + } + if (startID) { + var startElement = document.getElementById(startID); + if (startElement.parentNode != popup) { + throw new Error("startElement is not in popup"); + } + if (!startElement) { + throw new Error("startID does not correspond to an existing element"); + } + var endElement = null; + if (endID) { + endElement = document.getElementById(endID); + if (endElement.parentNode != popup) { + throw new Error("endElement is not in popup"); + } + if (!endElement) { + throw new Error("endID does not correspond to an existing element"); + } + } + while (startElement.nextSibling != endElement) { + popup.removeChild(startElement.nextSibling); + } + return endElement; + } + while (popup.hasChildNodes()) { + popup.firstChild.remove(); + } + return null; + }, + + /** + * Fills a menupopup with a list of columns + * + * @param {object} event + * The popupshowing event that invoked this function. + * @param {string} startID + * see _clean + * @param {string} endID + * see _clean + * @param {string} type + * the type of the menuitem, e.g. "radio" or "checkbox". + * Can be null (no-type). + * Checkboxes are checked if the column is visible. + * @param {boolean} localize + * If localize is true, the column label and accesskey are set + * via DOM Localization. + * If localize is false, the column label is used as label and + * no accesskey is assigned. + */ + fillWithColumns: function VM_fillWithColumns( + event, + startID, + endID, + type, + localize + ) { + var popup = event.target; + var pivot = this._clean(popup, startID, endID); + + var content = document.getElementById("placeContent"); + var columns = content.columns; + for (var i = 0; i < columns.count; ++i) { + var column = columns.getColumnAt(i).element; + var menuitem = document.createXULElement("menuitem"); + menuitem.id = "menucol_" + column.id; + menuitem.column = column; + if (localize) { + const l10nId = SORTBY_L10N_IDS.get(column.getAttribute("anonid")); + document.l10n.setAttributes(menuitem, l10nId); + } else { + const label = column.getAttribute("label"); + menuitem.setAttribute("label", label); + } + if (type == "radio") { + menuitem.setAttribute("type", "radio"); + menuitem.setAttribute("name", "columns"); + // This column is the sort key. Its item is checked. + if (column.getAttribute("sortDirection") != "") { + menuitem.setAttribute("checked", "true"); + } + } else if (type == "checkbox") { + menuitem.setAttribute("type", "checkbox"); + // Cannot uncheck the primary column. + if (column.getAttribute("primary") == "true") { + menuitem.setAttribute("disabled", "true"); + } + // Items for visible columns are checked. + if (!column.hidden) { + menuitem.setAttribute("checked", "true"); + } + } + if (pivot) { + popup.insertBefore(menuitem, pivot); + } else { + popup.appendChild(menuitem); + } + } + event.stopPropagation(); + }, + + /** + * Set up the content of the view menu. + * + * @param {object} event + * The event that invoked this function + */ + populateSortMenu: function VM_populateSortMenu(event) { + this.fillWithColumns( + event, + "viewUnsorted", + "directionSeparator", + "radio", + true + ); + + var sortColumn = this._getSortColumn(); + var viewSortAscending = document.getElementById("viewSortAscending"); + var viewSortDescending = document.getElementById("viewSortDescending"); + // We need to remove an existing checked attribute because the unsorted + // menu item is not rebuilt every time we open the menu like the others. + var viewUnsorted = document.getElementById("viewUnsorted"); + if (!sortColumn) { + viewSortAscending.removeAttribute("checked"); + viewSortDescending.removeAttribute("checked"); + viewUnsorted.setAttribute("checked", "true"); + } else if (sortColumn.getAttribute("sortDirection") == "ascending") { + viewSortAscending.setAttribute("checked", "true"); + viewSortDescending.removeAttribute("checked"); + viewUnsorted.removeAttribute("checked"); + } else if (sortColumn.getAttribute("sortDirection") == "descending") { + viewSortDescending.setAttribute("checked", "true"); + viewSortAscending.removeAttribute("checked"); + viewUnsorted.removeAttribute("checked"); + } + }, + + /** + * Shows/Hides a tree column. + * + * @param {object} element + * The menuitem element for the column + */ + showHideColumn: function VM_showHideColumn(element) { + var column = element.column; + + var splitter = column.nextSibling; + if (splitter && splitter.localName != "splitter") { + splitter = null; + } + + const isChecked = element.getAttribute("checked") == "true"; + column.hidden = !isChecked; + if (splitter) { + splitter.hidden = !isChecked; + } + }, + + /** + * Gets the last column that was sorted. + * + * @returns {object|null} the currently sorted column, null if there is no sorted column. + */ + _getSortColumn: function VM__getSortColumn() { + var content = document.getElementById("placeContent"); + var cols = content.columns; + for (var i = 0; i < cols.count; ++i) { + var column = cols.getColumnAt(i).element; + var sortDirection = column.getAttribute("sortDirection"); + if (sortDirection == "ascending" || sortDirection == "descending") { + return column; + } + } + return null; + }, + + /** + * Sorts the view by the specified column. + * + * @param {object} aColumn + * The colum that is the sort key. Can be null - the + * current sort column or the title column will be used. + * @param {string} aDirection + * The direction to sort - "ascending" or "descending". + * Can be null - the last direction or descending will be used. + * + * If both aColumnID and aDirection are null, the view will be unsorted. + */ + setSortColumn: function VM_setSortColumn(aColumn, aDirection) { + var result = document.getElementById("placeContent").result; + if (!aColumn && !aDirection) { + result.sortingMode = Ci.nsINavHistoryQueryOptions.SORT_BY_NONE; + return; + } + + var columnId; + if (aColumn) { + columnId = aColumn.getAttribute("anonid"); + if (!aDirection) { + let sortColumn = this._getSortColumn(); + if (sortColumn) { + aDirection = sortColumn.getAttribute("sortDirection"); + } + } + } else { + let sortColumn = this._getSortColumn(); + columnId = sortColumn ? sortColumn.getAttribute("anonid") : "title"; + } + + // This maps the possible values of columnId (i.e., anonid's of treecols in + // placeContent) to the default sortingMode for each column. + // key: Sort key in the name of one of the + // nsINavHistoryQueryOptions.SORT_BY_* constants + // dir: Default sort direction to use if none has been specified + const colLookupTable = { + title: { key: "TITLE", dir: "ascending" }, + tags: { key: "TAGS", dir: "ascending" }, + url: { key: "URI", dir: "ascending" }, + date: { key: "DATE", dir: "descending" }, + visitCount: { key: "VISITCOUNT", dir: "descending" }, + dateAdded: { key: "DATEADDED", dir: "descending" }, + lastModified: { key: "LASTMODIFIED", dir: "descending" }, + }; + + // Make sure we have a valid column. + if (!colLookupTable.hasOwnProperty(columnId)) { + throw new Error("Invalid column"); + } + + // Use a default sort direction if none has been specified. If aDirection + // is invalid, result.sortingMode will be undefined, which has the effect + // of unsorting the tree. + aDirection = (aDirection || colLookupTable[columnId].dir).toUpperCase(); + + var sortConst = + "SORT_BY_" + colLookupTable[columnId].key + "_" + aDirection; + result.sortingMode = Ci.nsINavHistoryQueryOptions[sortConst]; + }, +}; + +var ContentArea = { + _specialViews: new Map(), + + init: function CA_init() { + this._box = document.getElementById("placesViewsBox"); + this._toolbar = document.getElementById("placesToolbar"); + ContentTree.init(); + this._setupView(); + }, + + /** + * Gets the content view to be used for loading the given query. + * If a custom view was set by setContentViewForQueryString, that + * view would be returned, else the default tree view is returned + * + * @param {string} aQueryString + * a query string + * @returns {object} the view to be used for loading aQueryString. + */ + getContentViewForQueryString: function CA_getContentViewForQueryString( + aQueryString + ) { + try { + if (this._specialViews.has(aQueryString)) { + let { view, options } = this._specialViews.get(aQueryString); + if (typeof view == "function") { + view = view(); + this._specialViews.set(aQueryString, { view, options }); + } + return view; + } + } catch (ex) { + console.error(ex); + } + return ContentTree.view; + }, + + /** + * Sets a custom view to be used rather than the default places tree + * whenever the given query is selected in the left pane. + * + * @param {string} aQueryString + * a query string + * @param {object} aView + * Either the custom view or a function that will return the view + * the first (and only) time it's called. + * @param {object} [aOptions] + * Object defining special options for the view. + * @see ContentTree.viewOptions for supported options and default values. + */ + setContentViewForQueryString: function CA_setContentViewForQueryString( + aQueryString, + aView, + aOptions + ) { + if ( + !aQueryString || + (typeof aView != "object" && typeof aView != "function") + ) { + throw new Error("Invalid arguments"); + } + + this._specialViews.set(aQueryString, { + view: aView, + options: aOptions || {}, + }); + }, + + get currentView() { + let selectedPane = [...this._box.children].filter( + child => !child.hidden + )[0]; + return PlacesUIUtils.getViewForNode(selectedPane); + }, + set currentView(aNewView) { + let oldView = this.currentView; + if (oldView != aNewView) { + oldView.associatedElement.hidden = true; + aNewView.associatedElement.hidden = false; + + // If the content area inactivated view was focused, move focus + // to the new view. + if (document.activeElement == oldView.associatedElement) { + aNewView.associatedElement.focus(); + } + } + }, + + get currentPlace() { + return this.currentView.place; + }, + set currentPlace(aQueryString) { + let oldView = this.currentView; + let newView = this.getContentViewForQueryString(aQueryString); + newView.place = aQueryString; + if (oldView != newView) { + oldView.active = false; + this.currentView = newView; + this._setupView(); + newView.active = true; + } + }, + + /** + * Applies view options. + */ + _setupView: function CA__setupView() { + let options = this.currentViewOptions; + + // showDetailsPane. + let detailsPane = document.getElementById("detailsPane"); + detailsPane.hidden = !options.showDetailsPane; + + // toolbarSet. + for (let elt of this._toolbar.childNodes) { + // On Windows and Linux the menu buttons are menus wrapped in a menubar. + if (elt.id == "placesMenu") { + for (let menuElt of elt.childNodes) { + menuElt.hidden = !options.toolbarSet.includes(menuElt.id); + } + } else { + elt.hidden = !options.toolbarSet.includes(elt.id); + } + } + }, + + /** + * Options for the current view. + * + * @see {@link ContentTree.viewOptions} for supported options and default values. + * @returns {{showDetailsPane: boolean;toolbarSet: string;}} + */ + get currentViewOptions() { + // Use ContentTree options as default. + let viewOptions = ContentTree.viewOptions; + if (this._specialViews.has(this.currentPlace)) { + let { options } = this._specialViews.get(this.currentPlace); + for (let option in options) { + viewOptions[option] = options[option]; + } + } + return viewOptions; + }, + + focus() { + this.currentView.associatedElement.focus(); + }, +}; + +var ContentTree = { + init: function CT_init() { + this._view = document.getElementById("placeContent"); + }, + + get view() { + return this._view; + }, + + get viewOptions() { + return Object.seal({ + showDetailsPane: true, + toolbarSet: + "back-button, forward-button, organizeButton, viewMenu, maintenanceButton, libraryToolbarSpacer, searchFilter", + }); + }, + + openSelectedNode: function CT_openSelectedNode(aEvent) { + let view = this.view; + PlacesUIUtils.openNodeWithEvent(view.selectedNode, aEvent); + }, + + onClick: function CT_onClick(aEvent) { + let node = this.view.selectedNode; + if (node) { + let doubleClick = aEvent.button == 0 && aEvent.detail == 2; + let middleClick = aEvent.button == 1 && aEvent.detail == 1; + if (PlacesUtils.nodeIsURI(node) && (doubleClick || middleClick)) { + // Open associated uri in the browser. + this.openSelectedNode(aEvent); + } else if (middleClick && PlacesUtils.nodeIsContainer(node)) { + // The command execution function will take care of seeing if the + // selection is a folder or a different container type, and will + // load its contents in tabs. + PlacesUIUtils.openMultipleLinksInTabs(node, aEvent, this.view); + } + } + }, + + onKeyPress: function CT_onKeyPress(aEvent) { + if (aEvent.keyCode == KeyEvent.DOM_VK_RETURN) { + this.openSelectedNode(aEvent); + } + }, +}; diff --git a/browser/components/places/content/places.xhtml b/browser/components/places/content/places.xhtml new file mode 100644 index 0000000000..e1ac09878b --- /dev/null +++ b/browser/components/places/content/places.xhtml @@ -0,0 +1,429 @@ +<?xml version="1.0"?> + +# 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/. + +<!DOCTYPE window> + +<window id="places" + data-l10n-id="places-library3" + windowtype="Places:Organizer" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + onload="PlacesOrganizer.init();" + onunload="PlacesOrganizer.destroy();" + width="800" height="500" + screenX="10" screenY="10" + toggletoolbar="true" + persist="width height screenX screenY sizemode"> + + <linkset> + <html:link + rel="stylesheet" + href="chrome://browser/content/places/places.css" + /> + <html:link + rel="stylesheet" + href="chrome://browser/content/usercontext/usercontext.css" + /> + + <html:link rel="stylesheet" href="chrome://global/skin/global.css" /> + <html:link + rel="stylesheet" + href="chrome://browser/skin/places/tree-icons.css" + /> + <html:link + rel="stylesheet" + href="chrome://browser/skin/places/editBookmark.css" + /> + <html:link + rel="stylesheet" + href="chrome://browser/skin/places/organizer-shared.css" + /> + <html:link + rel="stylesheet" + href="chrome://browser/skin/places/organizer.css" + /> + + <html:link + rel="stylesheet" + href="chrome://browser/content/downloads/downloads.css" + /> + <html:link + rel="stylesheet" + href="chrome://browser/skin/downloads/allDownloadsView.css" + /> + + <html:link rel="localization" href="toolkit/global/textActions.ftl"/> + <html:link rel="localization" href="browser/browserSets.ftl"/> + <html:link rel="localization" href="browser/places.ftl"/> + <html:link rel="localization" href="browser/downloads.ftl"/> + <html:link rel="localization" href="browser/editBookmarkOverlay.ftl"/> + </linkset> + + <script src="chrome://browser/content/places/places.js"/> + <script src="chrome://global/content/editMenuOverlay.js"/> +#ifndef XP_MACOSX + <!-- On Mac, this is included via macWindow.inc.xhtml -> global-scripts.inc -> browser.js -> defineLazyScriptGetter --> + <script src="chrome://browser/content/places/editBookmark.js"/> + <!-- On Mac, thes are included via macWindow.inc.xhtml -> global-scripts.inc --> + <script src="chrome://global/content/globalOverlay.js"/> + <script src="chrome://browser/content/utilityOverlay.js"/> +#endif + +#ifdef XP_MACOSX +#include ../../../base/content/macWindow.inc.xhtml +#else +#include placesCommands.inc.xhtml +#endif + + <!-- This must be included after macWindow.inc.xhtml to override DownloadsView --> + <script src="chrome://browser/content/downloads/allDownloadsView.js"/> + <script src="chrome://global/content/contentAreaUtils.js"/> + <script src="chrome://browser/content/places/places-tree.js"/> + + <commandset id="organizerCommandSet"> + <command id="OrganizerCommand_find:all" + oncommand="PlacesSearchBox.findAll();"/> + <command id="OrganizerCommand_export" + oncommand="PlacesOrganizer.exportBookmarks();"/> + <command id="OrganizerCommand_import" + oncommand="PlacesOrganizer.importFromFile();"/> + <command id="OrganizerCommand_browserImport" + oncommand="PlacesOrganizer.importFromBrowser();"/> + <command id="OrganizerCommand_backup" + oncommand="PlacesOrganizer.backupBookmarks();"/> + <command id="OrganizerCommand_restoreFromFile" + oncommand="PlacesOrganizer.onRestoreBookmarksFromFile();"/> + <command id="OrganizerCommand_search:save" + oncommand="PlacesOrganizer.saveSearch();"/> + <command id="OrganizerCommand_search:moreCriteria" + oncommand="PlacesQueryBuilder.addRow();"/> + <command id="OrganizerCommand:Back" + oncommand="PlacesOrganizer.back();"/> + <command id="OrganizerCommand:Forward" + oncommand="PlacesOrganizer.forward();"/> + </commandset> +#include ../../downloads/content/downloadsCommands.inc.xhtml + + <keyset id="placesOrganizerKeyset"> + <!-- Instantiation Keys --> + <key id="placesKey_close" data-l10n-id="places-cmd-close" modifiers="accel" + oncommand="window.close();"/> + + <!-- Command Keys --> + <key id="placesKey_find:all" + command="OrganizerCommand_find:all" + data-l10n-id="places-cmd-find-key" + modifiers="accel"/> + + <!-- Back/Forward Keys Support --> +#ifndef XP_MACOSX + <key id="placesKey_goBackKb" + keycode="VK_LEFT" + command="OrganizerCommand:Back" + modifiers="alt"/> + <key id="placesKey_goForwardKb" + keycode="VK_RIGHT" + command="OrganizerCommand:Forward" + modifiers="alt"/> +#else + <key id="placesKey_goBackKb" + keycode="VK_LEFT" + command="OrganizerCommand:Back" + modifiers="accel"/> + <key id="placesKey_goForwardKb" + keycode="VK_RIGHT" + command="OrganizerCommand:Forward" + modifiers="accel"/> +#endif +#ifdef XP_UNIX + <key id="placesKey_goBackKb2" + data-l10n-id="nav-back-shortcut-alt" + command="OrganizerCommand:Back" + modifiers="accel"/> + <key id="placesKey_goForwardKb2" + data-l10n-id="nav-fwd-shortcut-alt" + command="OrganizerCommand:Forward" + modifiers="accel"/> +#endif + </keyset> + +#include ../../../../toolkit/content/editMenuKeys.inc.xhtml +#ifdef XP_MACOSX + <keyset id="editMenuKeysExtra"> + <key id="key_delete2" keycode="VK_BACK" command="cmd_delete"/> + </keyset> +#endif + + <popupset id="placesPopupset"> +#include placesContextMenu.inc.xhtml + <menupopup id="placesColumnsContext" + onpopupshowing="ViewMenu.fillWithColumns(event, null, null, 'checkbox', false);" + oncommand="ViewMenu.showHideColumn(event.target); event.stopPropagation();"/> +#include ../../downloads/content/downloadsContextMenu.inc.xhtml + </popupset> + + <toolbox id="placesToolbox"> + <toolbar class="chromeclass-toolbar" id="placesToolbar" align="center"> + <toolbarbutton id="back-button" + command="OrganizerCommand:Back" + data-l10n-id="places-back-button" + disabled="true"/> + + <toolbarbutton id="forward-button" + command="OrganizerCommand:Forward" + data-l10n-id="places-forward-button" + disabled="true"/> + +#ifdef XP_MACOSX + <toolbarbutton type="menu" class="tabbable" wantdropmarker="true" + onpopupshowing="document.getElementById('placeContent').focus()" + data-l10n-id="places-organize-button-mac" +#else + <menubar id="placesMenu"> + <menu class="menu-iconic" data-l10n-id="places-organize-button" +#endif + id="organizeButton"> + <menupopup id="organizeButtonPopup"> + <menuitem id="newbookmark" + command="placesCmd_new:bookmark" + data-l10n-id="places-add-bookmark"/> + <menuitem id="newfolder" + command="placesCmd_new:folder" + data-l10n-id="places-add-folder"/> + <menuitem id="newseparator" + command="placesCmd_new:separator" + data-l10n-id="places-add-separator"/> + +#ifdef XP_MACOSX + <menuseparator id="orgDeleteSeparator"/> + + <menuitem id="orgDelete" + command="cmd_delete" + data-l10n-id="text-action-delete" + key="key_delete"/> +#else + <menuseparator id="orgUndoSeparator"/> + + <menuitem id="orgUndo" + command="cmd_undo" + data-l10n-id="text-action-undo" + key="key_undo"/> + <menuitem id="orgRedo" + command="cmd_redo" + data-l10n-id="text-action-redo" + key="key_redo"/> + + <menuseparator id="orgCutSeparator"/> + + <menuitem id="orgCut" + command="cmd_cut" + data-l10n-id="text-action-cut" + key="key_cut" + selection="separator|link|folder|mixed"/> + <menuitem id="orgCopy" + command="cmd_copy" + data-l10n-id="text-action-copy" + key="key_copy" + selection="separator|link|folder|mixed"/> + <menuitem id="orgPaste" + command="cmd_paste" + data-l10n-id="text-action-paste" + key="key_paste" + selection="mutable"/> + <menuitem id="orgDelete" + command="cmd_delete" + data-l10n-id="text-action-delete" + key="key_delete"/> + + <menuseparator id="selectAllSeparator"/> + + <menuitem id="orgSelectAll" + command="cmd_selectAll" + data-l10n-id="text-action-select-all" + key="key_selectAll"/> + + <menuseparator id="orgCloseSeparator"/> + + <menuitem id="orgClose" + key="placesKey_close" + data-l10n-id="places-file-close" + oncommand="window.close();"/> +#endif + </menupopup> +#ifdef XP_MACOSX + </toolbarbutton> + <toolbarbutton type="menu" class="tabbable" wantdropmarker="true" + data-l10n-id="places-view-button-mac" +#else + </menu> + <menu class="menu-iconic" data-l10n-id="places-view-button" +#endif + id="viewMenu"> + <menupopup id="viewMenuPopup"> + + <menu id="viewColumns" + data-l10n-id="places-view-menu-columns"> + <menupopup onpopupshowing="ViewMenu.fillWithColumns(event, null, null, 'checkbox', false);" + oncommand="ViewMenu.showHideColumn(event.target); event.stopPropagation();"/> + </menu> + + <menu id="viewSort" data-l10n-id="places-view-menu-sort"> + <menupopup onpopupshowing="ViewMenu.populateSortMenu(event);" + oncommand="ViewMenu.setSortColumn(event.target.column, null);"> + <menuitem id="viewUnsorted" type="radio" name="columns" + data-l10n-id="places-view-sort-unsorted" + oncommand="ViewMenu.setSortColumn(null, null);"/> + <menuseparator id="directionSeparator"/> + <menuitem id="viewSortAscending" type="radio" name="direction" + data-l10n-id="places-view-sort-ascending" + oncommand="ViewMenu.setSortColumn(null, 'ascending'); event.stopPropagation();"/> + <menuitem id="viewSortDescending" type="radio" name="direction" + data-l10n-id="places-view-sort-descending" + oncommand="ViewMenu.setSortColumn(null, 'descending'); event.stopPropagation();"/> + </menupopup> + </menu> + </menupopup> +#ifdef XP_MACOSX + </toolbarbutton> + <toolbarbutton type="menu" class="tabbable" wantdropmarker="true" + data-l10n-id="places-maintenance-button-mac" +#else + </menu> + <menu class="menu-iconic" data-l10n-id="places-maintenance-button" +#endif + id="maintenanceButton"> + <menupopup id="maintenanceButtonPopup"> + <menuitem id="backupBookmarks" + command="OrganizerCommand_backup" + data-l10n-id="places-cmd-backup"/> + <menu id="fileRestoreMenu" data-l10n-id="places-cmd-restore"> + <menupopup id="fileRestorePopup" onpopupshowing="PlacesOrganizer.populateRestoreMenu();"> + <menuitem id="restoreFromFile" + command="OrganizerCommand_restoreFromFile" + data-l10n-id="places-cmd-restore-from-file"/> + </menupopup> + </menu> + <menuseparator/> + <menuitem id="fileImport" + command="OrganizerCommand_import" + data-l10n-id="places-import-bookmarks-from-html"/> + <menuitem id="fileExport" + command="OrganizerCommand_export" + data-l10n-id="places-export-bookmarks-to-html"/> + <menuseparator/> + <menuitem id="browserImport" + command="OrganizerCommand_browserImport" + data-l10n-id="places-import-other-browser"/> + </menupopup> +#ifdef XP_MACOSX + </toolbarbutton> +#else + </menu> + </menubar> +#endif + + <toolbarbutton id="clearDownloadsButton" + data-l10n-id="downloads-clear-downloads-button" + class="tabbable" + command="downloadsCmd_clearDownloads"/> + + <spacer id="libraryToolbarSpacer" flex="1"/> + + <search-textbox id="searchFilter" + flex="1" + aria-controls="placeContent" + data-l10n-attrs="placeholder" + oncommand="PlacesSearchBox.search(this.value);" + collection="bookmarks"/> + </toolbar> + </toolbox> + + <hbox flex="1" id="placesView"> + <tree id="placesList" + class="plain placesTree" + is="places-tree" + hidecolumnpicker="true" context="placesContext" + onselect="PlacesOrganizer.onPlaceSelected(true);" + onclick="PlacesOrganizer.onPlacesListClick(event);" + onfocus="PlacesOrganizer.updateDetailsPane(event);" + seltype="single" + persist="style"> + <treecols> + <treecol anonid="title" flex="1" primary="true" hideheader="true"/> + </treecols> + <treechildren flex="1"/> + </tree> + <splitter collapse="none" persist="state"></splitter> + <vbox id="contentView"> + <vbox id="placesViewsBox" flex="1"> + <tree id="placeContent" + class="plain placesTree" + context="placesContext" + hidecolumnpicker="true" + flex="1" + is="places-tree" + flatList="true" + selectfirstnode="true" + enableColumnDrag="true" + onfocus="PlacesOrganizer.updateDetailsPane(event)" + onselect="PlacesOrganizer.updateDetailsPane(event)" + onkeypress="ContentTree.onKeyPress(event);"> + <treecols id="placeContentColumns" context="placesColumnsContext"> + <!-- + The below code may suggest that 'ordinal' is still a supported XUL + attribute. It is not. This is a crutch so that we can continue + persisting the CSS order attribute, which is the appropriate + replacement for the ordinal attribute but cannot yet + be easily persisted. The code that synchronizes the attribute with + the CSS lives in toolkit/content/widget/tree.js and is specific to + tree elements. + --> + <treecol data-l10n-id="places-view-sort-col-name" id="placesContentTitle" anonid="title" style="flex: 5 5 auto" primary="true" ordinal="1" + persist="width hidden ordinal sortActive sortDirection"/> + <splitter class="tree-splitter"/> + <treecol data-l10n-id="places-view-sort-col-tags" id="placesContentTags" anonid="tags" style="flex: 2 2 auto" + persist="width hidden ordinal sortActive sortDirection"/> + <splitter class="tree-splitter"/> + <treecol data-l10n-id="places-view-sort-col-url" id="placesContentUrl" anonid="url" style="flex: 5 5 auto" + persist="width hidden ordinal sortActive sortDirection"/> + <splitter class="tree-splitter"/> + <treecol data-l10n-id="places-view-sort-col-most-recent-visit" id="placesContentDate" anonid="date" style="flex: 1 auto" hidden="true" + persist="width hidden ordinal sortActive sortDirection"/> + <splitter class="tree-splitter"/> + <treecol data-l10n-id="places-view-sort-col-visit-count" id="placesContentVisitCount" anonid="visitCount" style="flex: 1 auto" hidden="true" + persist="width hidden ordinal sortActive sortDirection"/> + <splitter class="tree-splitter"/> + <treecol data-l10n-id="places-view-sort-col-date-added" id="placesContentDateAdded" anonid="dateAdded" style="flex: 1 auto" hidden="true" + persist="width hidden ordinal sortActive sortDirection"/> + <splitter class="tree-splitter"/> + <treecol data-l10n-id="places-view-sort-col-last-modified" id="placesContentLastModified" anonid="lastModified" style="flex: 1 auto" hidden="true" + persist="width hidden ordinal sortActive sortDirection"/> + </treecols> + <treechildren flex="1" onclick="ContentTree.onClick(event);"/> + </tree> + <richlistbox flex="1" + hidden="true" + seltype="multiple" + id="downloadsListBox" + class="allDownloadsListBox" + context="downloadsContextMenu"/> + </vbox> + <vbox id="detailsPane"> + <vbox id="itemsCountBox" align="center" flex="1" hidden="true"> + <spacer style="flex: 3 3"/> + <label id="itemsCountText"/> + <spacer flex="1"/> + <description id="selectItemDescription" data-l10n-id="places-details-pane-select-an-item-description"> + </description> + <spacer style="flex: 3 3"/> + </vbox> + <vbox id="infoBox"> +#include editBookmarkPanel.inc.xhtml + </vbox> + </vbox> + </vbox> + </hbox> +</window> diff --git a/browser/components/places/content/placesCommands.inc.xhtml b/browser/components/places/content/placesCommands.inc.xhtml new file mode 100644 index 0000000000..a3676ba6b1 --- /dev/null +++ b/browser/components/places/content/placesCommands.inc.xhtml @@ -0,0 +1,52 @@ +# 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/. + +<commandset id="placesCommands" + commandupdater="true" + events="focus,sort,places" + oncommandupdate="PlacesUIUtils.updateCommands(window);"> + <command id="Browser:ShowAllBookmarks" + oncommand="PlacesCommandHook.showPlacesOrganizer('UnfiledBookmarks');"/> + <command id="Browser:ShowAllHistory" + oncommand="PlacesCommandHook.showPlacesOrganizer('History');"/> + + <command id="placesCmd_open" + oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_open');"/> + <command id="placesCmd_open:window" + oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_open:window');"/> + <command id="placesCmd_open:privatewindow" + oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_open:privatewindow');"/> + <command id="placesCmd_open:tab" + oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_open:tab');"/> + + <command id="placesCmd_new:bookmark" + oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_new:bookmark');"/> + <command id="placesCmd_new:folder" + oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_new:folder');"/> + <command id="placesCmd_new:separator" + oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_new:separator');"/> + <command id="placesCmd_show:info" + oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_show:info');"/> + <command id="placesCmd_rename" + oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_show:info');" + observes="placesCmd_show:info"/> + <command id="placesCmd_sortBy:name" + oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_sortBy:name');"/> + <command id="placesCmd_deleteDataHost" + oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_deleteDataHost');"/> + <command id="placesCmd_createBookmark" + oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_createBookmark');"/> + + <!-- Special versions of cut/copy/paste/delete which check for an open context menu. --> + <command id="placesCmd_cut" + oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_cut');"/> + <command id="placesCmd_copy" + oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_copy');"/> + <command id="placesCmd_paste" + oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_paste');"/> + <command id="placesCmd_delete" + oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_delete');"/> + <command id="placesCmd_showInFolder" + oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_showInFolder');"/> +</commandset> diff --git a/browser/components/places/content/placesContextMenu.inc.xhtml b/browser/components/places/content/placesContextMenu.inc.xhtml new file mode 100644 index 0000000000..eb557057a4 --- /dev/null +++ b/browser/components/places/content/placesContextMenu.inc.xhtml @@ -0,0 +1,178 @@ +# 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/. + +<menupopup id="placesContext" + onpopupshowing="return PlacesUIUtils.placesContextShowing(event);" + onpopuphiding="PlacesUIUtils.placesContextHiding(event);"> + <menuitem id="placesContext_open" + command="placesCmd_open" + data-l10n-id="places-open" + default="true" + selection-type="single" + node-type="link" + hide-if-single-click-opens="true"/> + <menuitem id="placesContext_openBookmarkContainer:tabs" + oncommand="PlacesUIUtils.openSelectionInTabs(event);" + data-l10n-id="places-open-all-bookmarks" + selection-type="single|none" + node-type="folder|query_tag"/> + <menuitem id="placesContext_openBookmarkLinks:tabs" + oncommand="PlacesUIUtils.openSelectionInTabs(event);" + data-l10n-id="places-open-all-bookmarks" + selection-type="multiple" + node-type="link_bookmark|separator"/> + <menuitem id="placesContext_open:newtab" + command="placesCmd_open:tab" + data-l10n-id="places-open-in-tab" + selection-type="single" + node-type="link"/> + <menu id="placesContext_open:newcontainertab" + data-l10n-id="places-open-in-container-tab" + selection-type="single" + node-type="link" + hide-if-private-browsing="true" + hide-if-usercontext-disabled="true"> + <menupopup oncommand="PlacesUIUtils.openInContainerTab(event);" + onpopupshowing="return PlacesUIUtils.createContainerTabMenu(event);" /> + </menu> + <menuitem id="placesContext_openContainer:tabs" + oncommand="PlacesUIUtils.openSelectionInTabs(event);" + data-l10n-id="places-open-all-in-tabs" + selection-type="single|none" + node-type="query" + hide-if-node-type="query_tag"/> + <menuitem id="placesContext_openLinks:tabs" + oncommand="PlacesUIUtils.openSelectionInTabs(event);" + data-l10n-id="places-open-all-in-tabs" + selection-type="multiple" + node-type="link" + hide-if-node-type="link_bookmark"/> + <menuitem id="placesContext_open:newwindow" + command="placesCmd_open:window" + data-l10n-id="places-open-in-window" + selection-type="single" + node-type="link"/> + <menuitem id="placesContext_open:newprivatewindow" + command="placesCmd_open:privatewindow" + data-l10n-id="places-open-in-private-window" + selection-type="single" + node-type="link" + hide-if-private-browsing="true"/> + <menuitem id="placesContext_showInFolder" + data-l10n-id="places-show-in-folder" + command="placesCmd_showInFolder" + closemenu="single" + node-type="link_bookmark" + hide-if-not-search="true" + selection-type="single"/> + <menuseparator id="placesContext_openSeparator"/> + <menuitem id="placesContext_show_bookmark:info" + command="placesCmd_show:info" + data-l10n-id="places-edit-bookmark" + node-type="link_bookmark"/> + <menuitem id="placesContext_show:info" + command="placesCmd_show:info" + data-l10n-id="places-edit-generic" + node-type="query" + hide-if-node-type="query_host|query_day"/> + <menuitem id="placesContext_show_folder:info" + command="placesCmd_show:info" + data-l10n-id="places-edit-folder2" + node-type="folder"/> + <menuitem id="placesContext_deleteBookmark" + data-l10n-id="places-delete-bookmark" + data-l10n-args='{"count":"1"}' + command="placesCmd_delete" + closemenu="single" + node-type="link_bookmark" + hide-if-node-type="link_bookmark_tag"/> + <menuitem id="placesContext_removeTag" + data-l10n-id="places-untag-bookmark" + command="placesCmd_delete" + closemenu="single" + node-type="link_bookmark_tag"/> + <menuitem id="placesContext_deleteFolder" + data-l10n-id="places-delete-folder" + data-l10n-args='{"count":"1"}' + command="placesCmd_delete" + node-type="folder" + closemenu="single"/> + <menuitem id="placesContext_delete" + data-l10n-id="text-action-delete" + command="placesCmd_delete" + closemenu="single" + hide-if-node-type-is-only="link|folder"/> + <menuitem id="placesContext_delete_history" + data-l10n-id="places-delete-page" + data-l10n-args='{"count":"1"}' + command="placesCmd_delete" + closemenu="single" + node-type="link" + hide-if-node-type="link_bookmark"/> + <menuitem id="placesContext_deleteHost" + command="placesCmd_deleteDataHost" + data-l10n-id="places-forget-domain-data" + closemenu="single" + node-type="link|query_host" + selection-type="single" + hide-if-node-type="link_bookmark"/> + <menuitem id="placesContext_sortBy:name" + command="placesCmd_sortBy:name" + data-l10n-id="places-sortby-name" + closemenu="single" + node-type="folder"/> + <menuseparator id="placesContext_deleteSeparator"/> + <menuitem id="placesContext_cut" + command="placesCmd_cut" + data-l10n-id="text-action-cut" + closemenu="single" + node-type="link_bookmark|folder|separator|query" + hide-if-node-type="link_bookmark_tag|query_host|query_day|query_tag"/> + <menuitem id="placesContext_copy" + command="placesCmd_copy" + data-l10n-id="text-action-copy" + closemenu="single"/> + <menuitem id="placesContext_paste_group" + data-l10n-id="text-action-paste" + command="placesCmd_paste" + closemenu="single" + hide-if-no-insertion-point="true"/> + <menuseparator id="placesContext_editSeparator"/> + <menuitem id="placesContext_new:bookmark" + command="placesCmd_new:bookmark" + data-l10n-id="places-add-bookmark" + selection-type="any" + hide-if-no-insertion-point="true"/> + <menuitem id="placesContext_new:folder" + command="placesCmd_new:folder" + data-l10n-id="places-add-folder-contextmenu" + selection-type="any" + hide-if-no-insertion-point="true"/> + <menuitem id="placesContext_new:separator" + command="placesCmd_new:separator" + data-l10n-id="places-add-separator" + closemenu="single" + selection-type="any" + hide-if-no-insertion-point="true"/> + <menuseparator id="placesContext_newSeparator"/> + <menuitem id="placesContext_paste" + data-l10n-id="text-action-paste" + command="placesCmd_paste" + closemenu="single" + selection-type="none" + hide-if-no-insertion-point="true"/> + <menuseparator id="placesContext_pasteSeparator"/> + <menuitem id="placesContext_createBookmark" + data-l10n-id="places-create-bookmark" + data-l10n-args='{"count":"1"}' + command="placesCmd_createBookmark" + node-type="link" + hide-if-node-type="link_bookmark"/> + <menuitem id="placesContext_showAllBookmarks" + data-l10n-id="places-manage-bookmarks" + command="Browser:ShowAllBookmarks" + ignore-item="true" + hidden="true"/> + +</menupopup> diff --git a/browser/components/places/content/treeView.js b/browser/components/places/content/treeView.js new file mode 100644 index 0000000000..f9b6eb6f9a --- /dev/null +++ b/browser/components/places/content/treeView.js @@ -0,0 +1,1862 @@ +/* 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 */ + +/** + * This returns the key for any node/details object. + * + * @param {object} nodeOrDetails + * A node, or an object containing the following properties: + * - uri + * - time + * - itemId + * In case any of these is missing, an empty string will be returned. This is + * to facilitate easy delete statements which occur due to assignment to items in `this._rows`, + * since the item we are deleting may be undefined in the array. + * + * @returns {string} key or empty string. + */ +function makeNodeDetailsKey(nodeOrDetails) { + if ( + nodeOrDetails && + typeof nodeOrDetails === "object" && + "uri" in nodeOrDetails && + "time" in nodeOrDetails && + "itemId" in nodeOrDetails + ) { + return `${nodeOrDetails.uri}*${nodeOrDetails.time}*${nodeOrDetails.itemId}`; + } + return ""; +} + +function PlacesTreeView(aContainer) { + this._tree = null; + this._result = null; + this._selection = null; + this._rootNode = null; + this._rows = []; + this._flatList = aContainer.flatList; + this._nodeDetails = new Map(); + this._element = aContainer; + this._controller = aContainer._controller; +} + +PlacesTreeView.prototype = { + get wrappedJSObject() { + return this; + }, + + QueryInterface: ChromeUtils.generateQI([ + "nsITreeView", + "nsINavHistoryResultObserver", + "nsISupportsWeakReference", + ]), + + /** + * This is called once both the result and the tree are set. + */ + _finishInit: function PTV__finishInit() { + let selection = this.selection; + if (selection) { + selection.selectEventsSuppressed = true; + } + + if (!this._rootNode.containerOpen) { + // This triggers containerStateChanged which then builds the visible + // section. + this._rootNode.containerOpen = true; + } else { + this.invalidateContainer(this._rootNode); + } + + // "Activate" the sorting column and update commands. + this.sortingChanged(this._result.sortingMode); + + if (selection) { + selection.selectEventsSuppressed = false; + } + }, + + uninit() { + if (this._editingObservers) { + for (let observer of this._editingObservers.values()) { + observer.disconnect(); + } + delete this._editingObservers; + } + // Break the reference cycle between the PlacesTreeView and result. + if (this._result) { + this._result.removeObserver(this); + } + }, + + /** + * Plain Container: container result nodes which may never include sub + * hierarchies. + * + * When the rows array is constructed, we don't set the children of plain + * containers. Instead, we keep placeholders for these children. We then + * build these children lazily as the tree asks us for information about each + * row. Luckily, the tree doesn't ask about rows outside the visible area. + * + * It's guaranteed that all containers are listed in the rows + * elements array. It's also guaranteed that separators (if they're not + * filtered, see below) are listed in the visible elements array, because + * bookmark folders are never built lazily, as described above. + * + * @see {@link PlacesTreeView._getNodeForRow} and + * {@link PlacesTreeView._getRowForNode} for the actual magic. + * + * @param {object} aContainer + * A container result node. + * + * @returns {boolean} true if aContainer is a plain container, false otherwise. + */ + _isPlainContainer: function PTV__isPlainContainer(aContainer) { + // We don't know enough about non-query containers. + if (!(aContainer instanceof Ci.nsINavHistoryQueryResultNode)) { + return false; + } + + switch (aContainer.queryOptions.resultType) { + case Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_QUERY: + case Ci.nsINavHistoryQueryOptions.RESULTS_AS_SITE_QUERY: + case Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_SITE_QUERY: + case Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAGS_ROOT: + case Ci.nsINavHistoryQueryOptions.RESULTS_AS_ROOTS_QUERY: + case Ci.nsINavHistoryQueryOptions.RESULTS_AS_LEFT_PANE_QUERY: + return false; + } + + // If it's a folder, it's not a plain container. + let nodeType = aContainer.type; + return ( + nodeType != Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER && + nodeType != Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT + ); + }, + + /** + * Gets the row number for a given node. Assumes that the given node is + * visible (i.e. it's not an obsolete node). + * + * If aParentRow and aNodeIndex are passed and parent is a plain + * container, this method will just return a calculated row value, without + * making assumptions on existence of the node at that position. + * + * @param {object} aNode + * A result node. Do not pass an obsolete node, or any + * node which isn't supposed to be in the tree (e.g. separators in + * sorted trees). + * @param {boolean} [aForceBuild] + * See {@link _isPlainContainer}. + * If true, the row will be computed even if the node still isn't set + * in our rows array. + * @param {object} [aParentRow] + * The row of aNode's parent. Ignored for the root node. + * @param {number} [aNodeIndex] + * The index of aNode in its parent. Only used if aParentRow is + * set too. + * + * @throws if aNode is invisible. + * @returns {object} aNode's row if it's in the rows list or if aForceBuild is set, -1 + * otherwise. + */ + _getRowForNode: function PTV__getRowForNode( + aNode, + aForceBuild, + aParentRow, + aNodeIndex + ) { + if (aNode == this._rootNode) { + throw new Error("The root node is never visible"); + } + + // A node is removed form the view either if it has no parent or if its + // root-ancestor is not the root node (in which case that's the node + // for which nodeRemoved was called). + let ancestors = Array.from(PlacesUtils.nodeAncestors(aNode)); + if ( + !ancestors.length || + ancestors[ancestors.length - 1] != this._rootNode + ) { + throw new Error("Removed node passed to _getRowForNode"); + } + + // Ensure that the entire chain is open, otherwise that node is invisible. + for (let ancestor of ancestors) { + if (!ancestor.containerOpen) { + throw new Error("Invisible node passed to _getRowForNode"); + } + } + + // Non-plain containers are initially built with their contents. + let parent = aNode.parent; + let parentIsPlain = this._isPlainContainer(parent); + if (!parentIsPlain) { + if (parent == this._rootNode) { + return this._rows.indexOf(aNode); + } + + return this._rows.indexOf(aNode, aParentRow); + } + + let row = -1; + let useNodeIndex = typeof aNodeIndex == "number"; + if (parent == this._rootNode) { + row = useNodeIndex ? aNodeIndex : this._rootNode.getChildIndex(aNode); + } else if (useNodeIndex && typeof aParentRow == "number") { + // If we have both the row of the parent node, and the node's index, we + // can avoid searching the rows array if the parent is a plain container. + row = aParentRow + aNodeIndex + 1; + } else { + // Look for the node in the nodes array. Start the search at the parent + // row. If the parent row isn't passed, we'll pass undefined to indexOf, + // which is fine. + row = this._rows.indexOf(aNode, aParentRow); + if (row == -1 && aForceBuild) { + let parentRow = + typeof aParentRow == "number" + ? aParentRow + : this._getRowForNode(parent); + row = parentRow + parent.getChildIndex(aNode) + 1; + } + } + + if (row != -1) { + this._nodeDetails.delete(makeNodeDetailsKey(this._rows[row])); + this._nodeDetails.set(makeNodeDetailsKey(aNode), aNode); + this._rows[row] = aNode; + } + + return row; + }, + + /** + * Given a row, finds and returns the parent details of the associated node. + * + * @param {number} aChildRow + * Row number. + * @returns {Array} [parentNode, parentRow] + */ + _getParentByChildRow: function PTV__getParentByChildRow(aChildRow) { + let node = this._getNodeForRow(aChildRow); + let parent = node === null ? this._rootNode : node.parent; + + // The root node is never visible + if (parent == this._rootNode) { + return [this._rootNode, -1]; + } + + let parentRow = this._rows.lastIndexOf(parent, aChildRow - 1); + return [parent, parentRow]; + }, + + /** + * Gets the node at a given row. + * + * @param {number} aRow + * The index of the row to set + * @returns {object} + */ + _getNodeForRow: function PTV__getNodeForRow(aRow) { + if (aRow < 0) { + return null; + } + + let node = this._rows[aRow]; + if (node !== undefined) { + return node; + } + + // Find the nearest node. + let rowNode, row; + for (let i = aRow - 1; i >= 0 && rowNode === undefined; i--) { + rowNode = this._rows[i]; + row = i; + } + + // If there's no container prior to the given row, it's a child of + // the root node (remember: all containers are listed in the rows array). + if (!rowNode) { + let newNode = this._rootNode.getChild(aRow); + this._nodeDetails.delete(makeNodeDetailsKey(this._rows[aRow])); + this._nodeDetails.set(makeNodeDetailsKey(newNode), newNode); + return (this._rows[aRow] = newNode); + } + + // Unset elements may exist only in plain containers. Thus, if the nearest + // node is a container, it's the row's parent, otherwise, it's a sibling. + if (rowNode instanceof Ci.nsINavHistoryContainerResultNode) { + let newNode = rowNode.getChild(aRow - row - 1); + this._nodeDetails.delete(makeNodeDetailsKey(this._rows[aRow])); + this._nodeDetails.set(makeNodeDetailsKey(newNode), newNode); + return (this._rows[aRow] = newNode); + } + + let [parent, parentRow] = this._getParentByChildRow(row); + let newNode = parent.getChild(aRow - parentRow - 1); + this._nodeDetails.delete(makeNodeDetailsKey(this._rows[aRow])); + this._nodeDetails.set(makeNodeDetailsKey(newNode), newNode); + return (this._rows[aRow] = newNode); + }, + + /** + * This takes a container and recursively appends our rows array per its + * contents. Assumes that the rows arrays has no rows for the given + * container. + * + * @param {object} aContainer + * A container result node. + * @param {object} aFirstChildRow + * The first row at which nodes may be inserted to the row array. + * In other words, that's aContainer's row + 1. + * @param {Array} aToOpen + * An array of containers to open once the build is done (out param) + * + * @returns {number} the number of rows which were inserted. + */ + _buildVisibleSection: function PTV__buildVisibleSection( + aContainer, + aFirstChildRow, + aToOpen + ) { + // There's nothing to do if the container is closed. + if (!aContainer.containerOpen) { + return 0; + } + + // Inserting the new elements into the rows array in one shot (by + // Array.prototype.concat) is faster than resizing the array (by splice) on each loop + // iteration. + let cc = aContainer.childCount; + let newElements = new Array(cc); + // We need to clean up the node details from aFirstChildRow + 1 to the end of rows. + for (let i = aFirstChildRow + 1; i < this._rows.length; i++) { + this._nodeDetails.delete(makeNodeDetailsKey(this._rows[i])); + } + this._rows = this._rows + .splice(0, aFirstChildRow) + .concat(newElements, this._rows); + + if (this._isPlainContainer(aContainer)) { + return cc; + } + + let sortingMode = this._result.sortingMode; + + let rowsInserted = 0; + for (let i = 0; i < cc; i++) { + let curChild = aContainer.getChild(i); + let curChildType = curChild.type; + + let row = aFirstChildRow + rowsInserted; + + // Don't display separators when sorted. + if (curChildType == Ci.nsINavHistoryResultNode.RESULT_TYPE_SEPARATOR) { + if (sortingMode != Ci.nsINavHistoryQueryOptions.SORT_BY_NONE) { + // Remove the element for the filtered separator. + // Notice that the rows array was initially resized to include all + // children. + this._nodeDetails.delete(makeNodeDetailsKey(this._rows[row])); + this._rows.splice(row, 1); + continue; + } + } + + this._nodeDetails.delete(makeNodeDetailsKey(this._rows[row])); + this._nodeDetails.set(makeNodeDetailsKey(curChild), curChild); + this._rows[row] = curChild; + rowsInserted++; + + // Recursively do containers. + if ( + !this._flatList && + curChild instanceof Ci.nsINavHistoryContainerResultNode + ) { + let uri = curChild.uri; + let isopen = false; + + if (uri) { + let val = Services.xulStore.getValue( + document.documentURI, + PlacesUIUtils.obfuscateUrlForXulStore(uri), + "open" + ); + isopen = val == "true"; + } + + if (isopen != curChild.containerOpen) { + aToOpen.push(curChild); + } else if (curChild.containerOpen && curChild.childCount > 0) { + rowsInserted += this._buildVisibleSection(curChild, row + 1, aToOpen); + } + } + } + + return rowsInserted; + }, + + /** + * This counts how many rows a node takes in the tree. For containers it + * will count the node itself plus any child node following it. + * + * @param {number} aNodeRow + * The row of the node to count + * @returns {number} + */ + _countVisibleRowsForNodeAtRow: function PTV__countVisibleRowsForNodeAtRow( + aNodeRow + ) { + let node = this._rows[aNodeRow]; + + // If it's not listed yet, we know that it's a leaf node (instanceof also + // null-checks). + if (!(node instanceof Ci.nsINavHistoryContainerResultNode)) { + return 1; + } + + let outerLevel = node.indentLevel; + for (let i = aNodeRow + 1; i < this._rows.length; i++) { + let rowNode = this._rows[i]; + if (rowNode && rowNode.indentLevel <= outerLevel) { + return i - aNodeRow; + } + } + + // This node plus its children take up the bottom of the list. + return this._rows.length - aNodeRow; + }, + + _getSelectedNodesInRange: function PTV__getSelectedNodesInRange( + aFirstRow, + aLastRow + ) { + let selection = this.selection; + let rc = selection.getRangeCount(); + if (rc == 0) { + return []; + } + + // The visible-area borders are needed for checking whether a + // selected row is also visible. + let firstVisibleRow = this._tree.getFirstVisibleRow(); + let lastVisibleRow = this._tree.getLastVisibleRow(); + + let nodesInfo = []; + for (let rangeIndex = 0; rangeIndex < rc; rangeIndex++) { + let min = {}, + max = {}; + selection.getRangeAt(rangeIndex, min, max); + + // If this range does not overlap the replaced chunk, we don't need to + // persist the selection. + if (max.value < aFirstRow || min.value > aLastRow) { + continue; + } + + let firstRow = Math.max(min.value, aFirstRow); + let lastRow = Math.min(max.value, aLastRow); + for (let i = firstRow; i <= lastRow; i++) { + nodesInfo.push({ + node: this._rows[i], + oldRow: i, + wasVisible: i >= firstVisibleRow && i <= lastVisibleRow, + }); + } + } + + return nodesInfo; + }, + + /** + * Tries to find an equivalent node for a node which was removed. We first + * look for the original node, in case it was just relocated. Then, if we + * that node was not found, we look for a node that has the same itemId, uri + * and time values. + * + * @param {object} aOldNode + * The node which was removed. + * + * @returns {number} the row number of an equivalent node for aOldOne, if one was + * found, -1 otherwise. + */ + _getNewRowForRemovedNode: function PTV__getNewRowForRemovedNode(aOldNode) { + let parent = aOldNode.parent; + if (parent) { + // If the node's parent is still set, the node is not obsolete + // and we should just find out its new position. + // However, if any of the node's ancestor is closed, the node is + // invisible. + let ancestors = PlacesUtils.nodeAncestors(aOldNode); + for (let ancestor of ancestors) { + if (!ancestor.containerOpen) { + return -1; + } + } + + return this._getRowForNode(aOldNode, true); + } + + // There's a broken edge case here. + // If a visit appears in two queries, and the second one was + // the old node, we'll select the first one after refresh. There's + // nothing we could do about that, because aOldNode.parent is + // gone by the time invalidateContainer is called. + let newNode = this._nodeDetails.get(makeNodeDetailsKey(aOldNode)); + + if (!newNode) { + return -1; + } + + return this._getRowForNode(newNode, true); + }, + + /** + * Restores a given selection state as near as possible to the original + * selection state. + * + * @param {Array} aNodesInfo + * The persisted selection state as returned by + * _getSelectedNodesInRange. + */ + _restoreSelection: function PTV__restoreSelection(aNodesInfo) { + if (!aNodesInfo.length) { + return; + } + + let selection = this.selection; + + // Attempt to ensure that previously-visible selection will be visible + // if it's re-selected. However, we can only ensure that for one row. + let scrollToRow = -1; + for (let i = 0; i < aNodesInfo.length; i++) { + let nodeInfo = aNodesInfo[i]; + let row = this._getNewRowForRemovedNode(nodeInfo.node); + // Select the found node, if any. + if (row != -1) { + selection.rangedSelect(row, row, true); + if (nodeInfo.wasVisible && scrollToRow == -1) { + scrollToRow = row; + } + } + } + + // If only one node was previously selected and there's no selection now, + // select the node at its old row, if any. + if (aNodesInfo.length == 1 && selection.count == 0) { + let row = Math.min(aNodesInfo[0].oldRow, this._rows.length - 1); + if (row != -1) { + selection.rangedSelect(row, row, true); + if (aNodesInfo[0].wasVisible && scrollToRow == -1) { + scrollToRow = aNodesInfo[0].oldRow; + } + } + } + + if (scrollToRow != -1) { + this._tree.ensureRowIsVisible(scrollToRow); + } + }, + + _convertPRTimeToString: function PTV__convertPRTimeToString(aTime) { + const MS_PER_MINUTE = 60000; + const MS_PER_DAY = 86400000; + let timeMs = aTime / 1000; // PRTime is in microseconds + + // Date is calculated starting from midnight, so the modulo with a day are + // milliseconds from today's midnight. + // getTimezoneOffset corrects that based on local time, notice midnight + // can have a different offset during DST-change days. + let dateObj = new Date(); + let now = dateObj.getTime() - dateObj.getTimezoneOffset() * MS_PER_MINUTE; + let midnight = now - (now % MS_PER_DAY); + midnight += new Date(midnight).getTimezoneOffset() * MS_PER_MINUTE; + + let timeObj = new Date(timeMs); + return timeMs >= midnight + ? this._todayFormatter.format(timeObj) + : this._dateFormatter.format(timeObj); + }, + + // We use a different formatter for times within the current day, + // so we cache both a "today" formatter and a general date formatter. + __todayFormatter: null, + get _todayFormatter() { + if (!this.__todayFormatter) { + const dtOptions = { timeStyle: "short" }; + this.__todayFormatter = new Services.intl.DateTimeFormat( + undefined, + dtOptions + ); + } + return this.__todayFormatter; + }, + + __dateFormatter: null, + get _dateFormatter() { + if (!this.__dateFormatter) { + const dtOptions = { + dateStyle: "short", + timeStyle: "short", + }; + this.__dateFormatter = new Services.intl.DateTimeFormat( + undefined, + dtOptions + ); + } + return this.__dateFormatter; + }, + + COLUMN_TYPE_UNKNOWN: 0, + COLUMN_TYPE_TITLE: 1, + COLUMN_TYPE_URI: 2, + COLUMN_TYPE_DATE: 3, + COLUMN_TYPE_VISITCOUNT: 4, + COLUMN_TYPE_DATEADDED: 5, + COLUMN_TYPE_LASTMODIFIED: 6, + COLUMN_TYPE_TAGS: 7, + + _getColumnType: function PTV__getColumnType(aColumn) { + let columnType = aColumn.element.getAttribute("anonid") || aColumn.id; + + switch (columnType) { + case "title": + return this.COLUMN_TYPE_TITLE; + case "url": + return this.COLUMN_TYPE_URI; + case "date": + return this.COLUMN_TYPE_DATE; + case "visitCount": + return this.COLUMN_TYPE_VISITCOUNT; + case "dateAdded": + return this.COLUMN_TYPE_DATEADDED; + case "lastModified": + return this.COLUMN_TYPE_LASTMODIFIED; + case "tags": + return this.COLUMN_TYPE_TAGS; + } + return this.COLUMN_TYPE_UNKNOWN; + }, + + _sortTypeToColumnType: function PTV__sortTypeToColumnType(aSortType) { + switch (aSortType) { + case Ci.nsINavHistoryQueryOptions.SORT_BY_TITLE_ASCENDING: + return [this.COLUMN_TYPE_TITLE, false]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_TITLE_DESCENDING: + return [this.COLUMN_TYPE_TITLE, true]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_ASCENDING: + return [this.COLUMN_TYPE_DATE, false]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING: + return [this.COLUMN_TYPE_DATE, true]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_URI_ASCENDING: + return [this.COLUMN_TYPE_URI, false]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_URI_DESCENDING: + return [this.COLUMN_TYPE_URI, true]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_VISITCOUNT_ASCENDING: + return [this.COLUMN_TYPE_VISITCOUNT, false]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_VISITCOUNT_DESCENDING: + return [this.COLUMN_TYPE_VISITCOUNT, true]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_DATEADDED_ASCENDING: + return [this.COLUMN_TYPE_DATEADDED, false]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_DATEADDED_DESCENDING: + return [this.COLUMN_TYPE_DATEADDED, true]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_LASTMODIFIED_ASCENDING: + return [this.COLUMN_TYPE_LASTMODIFIED, false]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_LASTMODIFIED_DESCENDING: + return [this.COLUMN_TYPE_LASTMODIFIED, true]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_TAGS_ASCENDING: + return [this.COLUMN_TYPE_TAGS, false]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_TAGS_DESCENDING: + return [this.COLUMN_TYPE_TAGS, true]; + } + return [this.COLUMN_TYPE_UNKNOWN, false]; + }, + + // nsINavHistoryResultObserver + nodeInserted: function PTV_nodeInserted(aParentNode, aNode, aNewIndex) { + console.assert(this._result, "Got a notification but have no result!"); + if (!this._tree || !this._result) { + return; + } + + // Bail out for hidden separators. + if (PlacesUtils.nodeIsSeparator(aNode) && this.isSorted()) { + return; + } + + let parentRow; + if (aParentNode != this._rootNode) { + parentRow = this._getRowForNode(aParentNode); + + // Update parent when inserting the first item, since twisty has changed. + if (aParentNode.childCount == 1) { + this._tree.invalidateRow(parentRow); + } + } + + // Compute the new row number of the node. + let row = -1; + let cc = aParentNode.childCount; + if (aNewIndex == 0 || this._isPlainContainer(aParentNode) || cc == 0) { + // We don't need to worry about sub hierarchies of the parent node + // if it's a plain container, or if the new node is its first child. + if (aParentNode == this._rootNode) { + row = aNewIndex; + } else { + row = parentRow + aNewIndex + 1; + } + } else { + // Here, we try to find the next visible element in the child list so we + // can set the new visible index to be right before that. Note that we + // have to search down instead of up, because some siblings could have + // children themselves that would be in the way. + let separatorsAreHidden = + PlacesUtils.nodeIsSeparator(aNode) && this.isSorted(); + for (let i = aNewIndex + 1; i < cc; i++) { + let node = aParentNode.getChild(i); + if (!separatorsAreHidden || PlacesUtils.nodeIsSeparator(node)) { + // The children have not been shifted so the next item will have what + // should be our index. + row = this._getRowForNode(node, false, parentRow, i); + break; + } + } + if (row < 0) { + // At the end of the child list without finding a visible sibling. This + // is a little harder because we don't know how many rows the last item + // in our list takes up (it could be a container with many children). + let prevChild = aParentNode.getChild(aNewIndex - 1); + let prevIndex = this._getRowForNode( + prevChild, + false, + parentRow, + aNewIndex - 1 + ); + row = prevIndex + this._countVisibleRowsForNodeAtRow(prevIndex); + } + } + + this._nodeDetails.set(makeNodeDetailsKey(aNode), aNode); + this._rows.splice(row, 0, aNode); + this._tree.rowCountChanged(row, 1); + + if ( + PlacesUtils.nodeIsContainer(aNode) && + PlacesUtils.asContainer(aNode).containerOpen + ) { + this.invalidateContainer(aNode); + } + }, + + /** + * THIS FUNCTION DOES NOT HANDLE cases where a collapsed node is being + * removed but the node it is collapsed with is not being removed (this then + * just swap out the removee with its collapsing partner). The only time + * when we really remove things is when deleting URIs, which will apply to + * all collapsees. This function is called sometimes when resorting items. + * However, we won't do this when sorted by date because dates will never + * change for visits, and date sorting is the only time things are collapsed. + * + * @param {object} aParentNode + * The parent node of the node being removed. + * @param {object} aNode + * The node to remove from the tree. + * @param {number} aOldIndex + * The old index of the node in the parent. + */ + nodeRemoved: function PTV_nodeRemoved(aParentNode, aNode, aOldIndex) { + console.assert(this._result, "Got a notification but have no result!"); + if (!this._tree || !this._result) { + return; + } + + // XXX bug 517701: We don't know what to do when the root node is removed. + if (aNode == this._rootNode) { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + // Bail out for hidden separators. + if (PlacesUtils.nodeIsSeparator(aNode) && this.isSorted()) { + return; + } + + let parentRow = + aParentNode == this._rootNode + ? undefined + : this._getRowForNode(aParentNode, true); + let oldRow = this._getRowForNode(aNode, true, parentRow, aOldIndex); + if (oldRow < 0) { + throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED); + } + + // If the node was exclusively selected, the node next to it will be + // selected. + let selectNext = false; + let selection = this.selection; + if (selection.getRangeCount() == 1) { + let min = {}, + max = {}; + selection.getRangeAt(0, min, max); + if (min.value == max.value && this.nodeForTreeIndex(min.value) == aNode) { + selectNext = true; + } + } + + // Remove the node and its children, if any. + let count = this._countVisibleRowsForNodeAtRow(oldRow); + for (let splicedNode of this._rows.splice(oldRow, count)) { + this._nodeDetails.delete(makeNodeDetailsKey(splicedNode)); + } + this._tree.rowCountChanged(oldRow, -count); + + // Redraw the parent if its twisty state has changed. + if (aParentNode != this._rootNode && !aParentNode.hasChildren) { + parentRow = oldRow - 1; + this._tree.invalidateRow(parentRow); + } + + // Restore selection if the node was exclusively selected. + if (!selectNext) { + return; + } + + // Restore selection. + let rowToSelect = Math.min(oldRow, this._rows.length - 1); + if (rowToSelect != -1) { + this.selection.rangedSelect(rowToSelect, rowToSelect, true); + } + }, + + nodeMoved: function PTV_nodeMoved( + aNode, + aOldParent, + aOldIndex, + aNewParent, + aNewIndex + ) { + console.assert(this._result, "Got a notification but have no result!"); + if (!this._tree || !this._result) { + return; + } + + // Bail out for hidden separators. + if (PlacesUtils.nodeIsSeparator(aNode) && this.isSorted()) { + return; + } + + // Note that at this point the node has already been moved by the backend, + // so we must give hints to _getRowForNode to get the old row position. + let oldParentRow = + aOldParent == this._rootNode + ? undefined + : this._getRowForNode(aOldParent, true); + let oldRow = this._getRowForNode(aNode, true, oldParentRow, aOldIndex); + if (oldRow < 0) { + throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED); + } + + // If this node is a container it could take up more than one row. + let count = this._countVisibleRowsForNodeAtRow(oldRow); + + // Persist selection state. + let nodesToReselect = this._getSelectedNodesInRange( + oldRow, + oldRow + count - 1 + ); + if (nodesToReselect.length) { + this.selection.selectEventsSuppressed = true; + } + + // Redraw the parent if its twisty state has changed. + if (aOldParent != this._rootNode && !aOldParent.hasChildren) { + let parentRow = oldRow - 1; + this._tree.invalidateRow(parentRow); + } + + // Remove node and its children, if any, from the old position. + for (let splicedNode of this._rows.splice(oldRow, count)) { + this._nodeDetails.delete(makeNodeDetailsKey(splicedNode)); + } + this._tree.rowCountChanged(oldRow, -count); + + // Insert the node into the new position. + this.nodeInserted(aNewParent, aNode, aNewIndex); + + // Restore selection. + if (nodesToReselect.length) { + this._restoreSelection(nodesToReselect); + this.selection.selectEventsSuppressed = false; + } + }, + + _invalidateCellValue: function PTV__invalidateCellValue(aNode, aColumnType) { + console.assert(this._result, "Got a notification but have no result!"); + if (!this._tree || !this._result) { + return; + } + + // Nothing to do for the root node. + if (aNode == this._rootNode) { + return; + } + + let row = this._getRowForNode(aNode); + if (row == -1) { + return; + } + + let column = this._findColumnByType(aColumnType); + if (column && !column.element.hidden) { + if (aColumnType == this.COLUMN_TYPE_TITLE) { + this._tree.removeImageCacheEntry(row, column); + } + this._tree.invalidateCell(row, column); + } + + // Last modified time is altered for almost all node changes. + if (aColumnType != this.COLUMN_TYPE_LASTMODIFIED) { + let lastModifiedColumn = this._findColumnByType( + this.COLUMN_TYPE_LASTMODIFIED + ); + if (lastModifiedColumn && !lastModifiedColumn.hidden) { + this._tree.invalidateCell(row, lastModifiedColumn); + } + } + }, + + nodeTitleChanged: function PTV_nodeTitleChanged(aNode, aNewTitle) { + this._invalidateCellValue(aNode, this.COLUMN_TYPE_TITLE); + }, + + nodeURIChanged: function PTV_nodeURIChanged(aNode, aOldURI) { + this._nodeDetails.delete( + makeNodeDetailsKey({ + uri: aOldURI, + itemId: aNode.itemId, + time: aNode.time, + }) + ); + this._nodeDetails.set(makeNodeDetailsKey(aNode), aNode); + this._invalidateCellValue(aNode, this.COLUMN_TYPE_URI); + }, + + nodeIconChanged: function PTV_nodeIconChanged(aNode) { + this._invalidateCellValue(aNode, this.COLUMN_TYPE_TITLE); + }, + + nodeHistoryDetailsChanged: function PTV_nodeHistoryDetailsChanged( + aNode, + aOldVisitDate, + aOldVisitCount + ) { + this._nodeDetails.delete( + makeNodeDetailsKey({ + uri: aNode.uri, + itemId: aNode.itemId, + time: aOldVisitDate, + }) + ); + this._nodeDetails.set(makeNodeDetailsKey(aNode), aNode); + + this._invalidateCellValue(aNode, this.COLUMN_TYPE_DATE); + this._invalidateCellValue(aNode, this.COLUMN_TYPE_VISITCOUNT); + }, + + nodeTagsChanged: function PTV_nodeTagsChanged(aNode) { + this._invalidateCellValue(aNode, this.COLUMN_TYPE_TAGS); + }, + + nodeKeywordChanged(aNode, aNewKeyword) {}, + + nodeDateAddedChanged: function PTV_nodeDateAddedChanged(aNode, aNewValue) { + this._invalidateCellValue(aNode, this.COLUMN_TYPE_DATEADDED); + }, + + nodeLastModifiedChanged: function PTV_nodeLastModifiedChanged( + aNode, + aNewValue + ) { + this._invalidateCellValue(aNode, this.COLUMN_TYPE_LASTMODIFIED); + }, + + containerStateChanged: function PTV_containerStateChanged( + aNode, + aOldState, + aNewState + ) { + this.invalidateContainer(aNode); + }, + + invalidateContainer: function PTV_invalidateContainer(aContainer) { + console.assert(this._result, "Need to have a result to update"); + if (!this._tree) { + return; + } + + // If we are currently editing, don't invalidate the container until we + // finish. + if (this._tree.getAttribute("editing")) { + if (!this._editingObservers) { + this._editingObservers = new Map(); + } + if (!this._editingObservers.has(aContainer)) { + let mutationObserver = new MutationObserver(() => { + Services.tm.dispatchToMainThread(() => + this.invalidateContainer(aContainer) + ); + let observer = this._editingObservers.get(aContainer); + observer.disconnect(); + this._editingObservers.delete(aContainer); + }); + + mutationObserver.observe(this._tree, { + attributes: true, + attributeFilter: ["editing"], + }); + + this._editingObservers.set(aContainer, mutationObserver); + } + return; + } + + let startReplacement, replaceCount; + if (aContainer == this._rootNode) { + startReplacement = 0; + replaceCount = this._rows.length; + + // If the root node is now closed, the tree is empty. + if (!this._rootNode.containerOpen) { + this._nodeDetails.clear(); + this._rows = []; + if (replaceCount) { + this._tree.rowCountChanged(startReplacement, -replaceCount); + } + + return; + } + } else { + // Update the twisty state. + let row = this._getRowForNode(aContainer); + this._tree.invalidateRow(row); + + // We don't replace the container node itself, so we should decrease the + // replaceCount by 1. + startReplacement = row + 1; + replaceCount = this._countVisibleRowsForNodeAtRow(row) - 1; + } + + // Persist selection state. + let nodesToReselect = this._getSelectedNodesInRange( + startReplacement, + startReplacement + replaceCount + ); + + // Now update the number of elements. + this.selection.selectEventsSuppressed = true; + + // First remove the old elements + for (let splicedNode of this._rows.splice(startReplacement, replaceCount)) { + this._nodeDetails.delete(makeNodeDetailsKey(splicedNode)); + } + + // If the container is now closed, we're done. + if (!aContainer.containerOpen) { + let oldSelectionCount = this.selection.count; + if (replaceCount) { + this._tree.rowCountChanged(startReplacement, -replaceCount); + } + + // Select the row next to the closed container if any of its + // children were selected, and nothing else is selected. + if ( + nodesToReselect.length && + nodesToReselect.length == oldSelectionCount + ) { + this.selection.rangedSelect(startReplacement, startReplacement, true); + this._tree.ensureRowIsVisible(startReplacement); + } + + this.selection.selectEventsSuppressed = false; + return; + } + + // Otherwise, start a batch first. + this._tree.beginUpdateBatch(); + if (replaceCount) { + this._tree.rowCountChanged(startReplacement, -replaceCount); + } + + let toOpenElements = []; + let elementsAddedCount = this._buildVisibleSection( + aContainer, + startReplacement, + toOpenElements + ); + if (elementsAddedCount) { + this._tree.rowCountChanged(startReplacement, elementsAddedCount); + } + + if (!this._flatList) { + // Now, open any containers that were persisted. + for (let i = 0; i < toOpenElements.length; i++) { + let item = toOpenElements[i]; + let parent = item.parent; + + // Avoid recursively opening containers. + while (parent) { + if (parent.uri == item.uri) { + break; + } + parent = parent.parent; + } + + // If we don't have a parent, we made it all the way to the root + // and didn't find a match, so we can open our item. + if (!parent && !item.containerOpen) { + item.containerOpen = true; + } + } + } + + this._tree.endUpdateBatch(); + + // Restore selection. + this._restoreSelection(nodesToReselect); + this.selection.selectEventsSuppressed = false; + }, + + _columns: [], + _findColumnByType: function PTV__findColumnByType(aColumnType) { + if (this._columns[aColumnType]) { + return this._columns[aColumnType]; + } + + let columns = this._tree.columns; + let colCount = columns.count; + for (let i = 0; i < colCount; i++) { + let column = columns.getColumnAt(i); + let columnType = this._getColumnType(column); + this._columns[columnType] = column; + if (columnType == aColumnType) { + return column; + } + } + + // That's completely valid. Most of our trees actually include just the + // title column. + return null; + }, + + sortingChanged: function PTV__sortingChanged(aSortingMode) { + if (!this._tree || !this._result) { + return; + } + + // Depending on the sort mode, certain commands may be disabled. + window.updateCommands("sort"); + + let columns = this._tree.columns; + + // Clear old sorting indicator. + let sortedColumn = columns.getSortedColumn(); + if (sortedColumn) { + sortedColumn.element.removeAttribute("sortDirection"); + } + + // Set new sorting indicator by looking through all columns for ours. + if (aSortingMode == Ci.nsINavHistoryQueryOptions.SORT_BY_NONE) { + return; + } + + let [desiredColumn, desiredIsDescending] = + this._sortTypeToColumnType(aSortingMode); + let column = this._findColumnByType(desiredColumn); + if (column) { + let sortDir = desiredIsDescending ? "descending" : "ascending"; + column.element.setAttribute("sortDirection", sortDir); + } + }, + + _inBatchMode: false, + batching: function PTV__batching(aToggleMode) { + if (this._inBatchMode != aToggleMode) { + this._inBatchMode = this.selection.selectEventsSuppressed = aToggleMode; + if (this._inBatchMode) { + this._tree.beginUpdateBatch(); + } else { + this._tree.endUpdateBatch(); + } + } + }, + + get result() { + return this._result; + }, + set result(val) { + if (this._result) { + this._result.removeObserver(this); + this._rootNode.containerOpen = false; + } + + if (val) { + this._result = val; + this._rootNode = this._result.root; + this._cellProperties = new Map(); + this._cuttingNodes = new Set(); + } else if (this._result) { + delete this._result; + delete this._rootNode; + delete this._cellProperties; + delete this._cuttingNodes; + } + + // If the tree is not set yet, setTree will call finishInit. + if (this._tree && val) { + this._finishInit(); + } + }, + + /** + * This allows you to get at the real node for a given row index. This is + * only valid when a tree is attached. + * + * @param {Integer} aIndex The index for the node to get. + * @returns {Ci.nsINavHistoryResultNode} The node. + * @throws Cr.NS_ERROR_INVALID_ARG if the index is greater than the number of + * rows. + */ + nodeForTreeIndex(aIndex) { + if (aIndex > this._rows.length) { + throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG); + } + + return this._getNodeForRow(aIndex); + }, + + /** + * Reverse of nodeForTreeIndex, returns the row index for a given result node. + * The node should be part of the tree. + * + * @param {Ci.nsINavHistoryResultNode} aNode The node to look for in the tree. + * @returns {Integer} The found index, or -1 if the item is not visible or not found. + */ + treeIndexForNode(aNode) { + // The API allows passing invisible nodes. + try { + return this._getRowForNode(aNode, true); + } catch (ex) {} + + return -1; + }, + + // nsITreeView + get rowCount() { + return this._rows.length; + }, + get selection() { + return this._selection; + }, + set selection(val) { + this._selection = val; + }, + + getRowProperties() { + return ""; + }, + + getCellProperties: function PTV_getCellProperties(aRow, aColumn) { + // for anonid-trees, we need to add the column-type manually + var props = ""; + let columnType = aColumn.element.getAttribute("anonid"); + if (columnType) { + props += columnType; + } else { + columnType = aColumn.id; + } + + // Set the "ltr" property on url cells + if (columnType == "url") { + props += " ltr"; + } + + if (columnType != "title") { + return props; + } + + let node = this._getNodeForRow(aRow); + + if (this._cuttingNodes.has(node)) { + props += " cutting"; + } + + let properties = this._cellProperties.get(node); + if (properties === undefined) { + properties = ""; + let itemId = node.itemId; + let nodeType = node.type; + if (PlacesUtils.containerTypes.includes(nodeType)) { + if (nodeType == Ci.nsINavHistoryResultNode.RESULT_TYPE_QUERY) { + properties += " query"; + if (PlacesUtils.nodeIsTagQuery(node)) { + properties += " tagContainer"; + } else if (PlacesUtils.nodeIsDay(node)) { + properties += " dayContainer"; + } else if (PlacesUtils.nodeIsHost(node)) { + properties += " hostContainer"; + } + } + + if (itemId == -1) { + switch (node.bookmarkGuid) { + case PlacesUtils.bookmarks.virtualToolbarGuid: + properties += ` queryFolder_${PlacesUtils.bookmarks.toolbarGuid}`; + break; + case PlacesUtils.bookmarks.virtualMenuGuid: + properties += ` queryFolder_${PlacesUtils.bookmarks.menuGuid}`; + break; + case PlacesUtils.bookmarks.virtualUnfiledGuid: + properties += ` queryFolder_${PlacesUtils.bookmarks.unfiledGuid}`; + break; + case PlacesUtils.virtualAllBookmarksGuid: + case PlacesUtils.virtualHistoryGuid: + case PlacesUtils.virtualDownloadsGuid: + case PlacesUtils.virtualTagsGuid: + properties += ` OrganizerQuery_${node.bookmarkGuid}`; + break; + } + } + } else if (nodeType == Ci.nsINavHistoryResultNode.RESULT_TYPE_SEPARATOR) { + properties += " separator"; + } else if (PlacesUtils.nodeIsURI(node)) { + properties += " " + PlacesUIUtils.guessUrlSchemeForUI(node.uri); + } + + this._cellProperties.set(node, properties); + } + + return props + " " + properties; + }, + + getColumnProperties(aColumn) { + return ""; + }, + + isContainer: function PTV_isContainer(aRow) { + // Only leaf nodes aren't listed in the rows array. + let node = this._rows[aRow]; + if (node === undefined || !PlacesUtils.nodeIsContainer(node)) { + return false; + } + + // Flat-lists may ignore expandQueries and other query options when + // they are asked to open a container. + if (this._flatList) { + return true; + } + + // Treat non-expandable childless queries as non-containers, unless they + // are tags. + if (PlacesUtils.nodeIsQuery(node) && !PlacesUtils.nodeIsTagQuery(node)) { + return ( + PlacesUtils.asQuery(node).queryOptions.expandQueries || node.hasChildren + ); + } + return true; + }, + + isContainerOpen: function PTV_isContainerOpen(aRow) { + if (this._flatList) { + return false; + } + + // All containers are listed in the rows array. + return this._rows[aRow].containerOpen; + }, + + isContainerEmpty: function PTV_isContainerEmpty(aRow) { + if (this._flatList) { + return true; + } + + // All containers are listed in the rows array. + return !this._rows[aRow].hasChildren; + }, + + isSeparator: function PTV_isSeparator(aRow) { + // All separators are listed in the rows array. + let node = this._rows[aRow]; + return node && PlacesUtils.nodeIsSeparator(node); + }, + + isSorted: function PTV_isSorted() { + return ( + this._result.sortingMode != Ci.nsINavHistoryQueryOptions.SORT_BY_NONE + ); + }, + + canDrop: function PTV_canDrop(aRow, aOrientation, aDataTransfer) { + if (!this._result) { + throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED); + } + + if (this._controller.disableUserActions) { + return false; + } + + // Drop position into a sorted treeview would be wrong. + if (this.isSorted()) { + return false; + } + + let ip = this._getInsertionPoint(aRow, aOrientation); + return ip && PlacesControllerDragHelper.canDrop(ip, aDataTransfer); + }, + + _getInsertionPoint: function PTV__getInsertionPoint(index, orientation) { + let container = this._result.root; + let dropNearNode = null; + // 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) { + let lastSelected = this.nodeForTreeIndex(index); + if (this.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 node is an open container and the user is + // trying to drag into it as a first node, really insert into it. + container = lastSelected; + orientation = Ci.nsITreeView.DROP_ON; + index = 0; + } else { + // Use the last-selected node's container. + container = lastSelected.parent; + + // During its Drag & Drop operation, the tree code closes-and-opens + // containers very often (part of the XUL "spring-loaded folders" + // implementation). And in certain cases, we may reach a closed + // container here. However, we can simply bail out when this happens, + // because we would then be back here in less than a millisecond, when + // the container had been reopened. + if (!container || !container.containerOpen) { + return null; + } + + // Don't show an insertion point if the index is contained + // within the selection and drag source is the same + if ( + this._element.isDragSource && + this._element.view.selection.isSelected(index) + ) { + 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; + } + + let queryOptions = PlacesUtils.asQuery(this._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 { + let lastSelectedIndex = container.getChildIndex(lastSelected); + index = + orientation == Ci.nsITreeView.DROP_BEFORE + ? lastSelectedIndex + : lastSelectedIndex + 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, + }); + }, + + async drop(aRow, aOrientation, aDataTransfer) { + if (this._controller.disableUserActions) { + return; + } + + // We are responsible for translating the |index| and |orientation| + // parameters into a container id and index within the container, + // since this information is specific to the tree view. + let ip = this._getInsertionPoint(aRow, aOrientation); + if (ip) { + try { + await PlacesControllerDragHelper.onDrop(ip, aDataTransfer, this._tree); + } catch (ex) { + console.error(ex); + } finally { + // We should only clear the drop target once + // the onDrop is complete, as it is an async function. + PlacesControllerDragHelper.currentDropTarget = null; + } + } + }, + + getParentIndex: function PTV_getParentIndex(aRow) { + let [, parentRow] = this._getParentByChildRow(aRow); + return parentRow; + }, + + hasNextSibling: function PTV_hasNextSibling(aRow, aAfterIndex) { + if (aRow == this._rows.length - 1) { + // The last row has no sibling. + return false; + } + + let node = this._rows[aRow]; + if (node === undefined || this._isPlainContainer(node.parent)) { + // The node is a child of a plain container. + // If the next row is either unset or has the same parent, + // it's a sibling. + let nextNode = this._rows[aRow + 1]; + return nextNode == undefined || nextNode.parent == node.parent; + } + + let thisLevel = node.indentLevel; + for (let i = aAfterIndex + 1; i < this._rows.length; ++i) { + let rowNode = this._getNodeForRow(i); + let nextLevel = rowNode.indentLevel; + if (nextLevel == thisLevel) { + return true; + } + if (nextLevel < thisLevel) { + break; + } + } + + return false; + }, + + getLevel(aRow) { + return this._getNodeForRow(aRow).indentLevel; + }, + + getImageSrc: function PTV_getImageSrc(aRow, aColumn) { + // Only the title column has an image. + if (this._getColumnType(aColumn) != this.COLUMN_TYPE_TITLE) { + return ""; + } + + let node = this._getNodeForRow(aRow); + return node.icon; + }, + + getCellValue(aRow, aColumn) {}, + + getCellText: function PTV_getCellText(aRow, aColumn) { + let node = this._getNodeForRow(aRow); + switch (this._getColumnType(aColumn)) { + case this.COLUMN_TYPE_TITLE: + // normally, this is just the title, but we don't want empty items in + // the tree view so return a special string if the title is empty. + // Do it here so that callers can still get at the 0 length title + // if they go through the "result" API. + if (PlacesUtils.nodeIsSeparator(node)) { + return ""; + } + return PlacesUIUtils.getBestTitle(node, true); + case this.COLUMN_TYPE_TAGS: + return node.tags?.replace(",", ", "); + case this.COLUMN_TYPE_URI: + if (PlacesUtils.nodeIsURI(node)) { + return node.uri; + } + return ""; + case this.COLUMN_TYPE_DATE: + let nodeTime = node.time; + if (nodeTime == 0 || !PlacesUtils.nodeIsURI(node)) { + // hosts and days shouldn't have a value for the date column. + // Actually, you could argue this point, but looking at the + // results, seeing the most recently visited date is not what + // I expect, and gives me no information I know how to use. + // Only show this for URI-based items. + return ""; + } + + return this._convertPRTimeToString(nodeTime); + case this.COLUMN_TYPE_VISITCOUNT: + return node.accessCount; + case this.COLUMN_TYPE_DATEADDED: + if (node.dateAdded) { + return this._convertPRTimeToString(node.dateAdded); + } + return ""; + case this.COLUMN_TYPE_LASTMODIFIED: + if (node.lastModified) { + return this._convertPRTimeToString(node.lastModified); + } + return ""; + } + return ""; + }, + + setTree: function PTV_setTree(aTree) { + // If we are replacing the tree during a batch, there is a concrete risk + // that the treeView goes out of sync, thus it's safer to end the batch now. + // This is a no-op if we are not batching. + this.batching(false); + + let hasOldTree = this._tree != null; + this._tree = aTree; + + if (this._result) { + if (hasOldTree) { + // detach from result when we are detaching from the tree. + // This breaks the reference cycle between us and the result. + if (!aTree) { + // Close the root container to free up memory and stop live updates. + this._rootNode.containerOpen = false; + } + } + if (aTree) { + this._finishInit(); + } + } + }, + + toggleOpenState: function PTV_toggleOpenState(aRow) { + if (!this._result) { + throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED); + } + + let node = this._rows[aRow]; + if (this._flatList && this._element) { + let event = new CustomEvent("onOpenFlatContainer", { detail: node }); + this._element.dispatchEvent(event); + return; + } + + let uri = node.uri; + + if (uri) { + let docURI = document.documentURI; + + if (node.containerOpen) { + Services.xulStore.removeValue( + docURI, + PlacesUIUtils.obfuscateUrlForXulStore(uri), + "open" + ); + } else { + Services.xulStore.setValue( + docURI, + PlacesUIUtils.obfuscateUrlForXulStore(uri), + "open", + "true" + ); + } + } + + node.containerOpen = !node.containerOpen; + }, + + cycleHeader: function PTV_cycleHeader(aColumn) { + if (!this._result) { + throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED); + } + + // Sometimes you want a tri-state sorting, and sometimes you don't. This + // rule allows tri-state sorting when the root node is a folder. This will + // catch the most common cases. When you are looking at folders, you want + // the third state to reset the sorting to the natural bookmark order. When + // you are looking at history, that third state has no meaning so we try + // to disallow it. + // + // The problem occurs when you have a query that results in bookmark + // folders. One example of this is the subscriptions view. In these cases, + // this rule doesn't allow you to sort those sub-folders by their natural + // order. + let allowTriState = PlacesUtils.nodeIsFolder(this._result.root); + + let oldSort = this._result.sortingMode; + let newSort; + const NHQO = Ci.nsINavHistoryQueryOptions; + switch (this._getColumnType(aColumn)) { + case this.COLUMN_TYPE_TITLE: + if (oldSort == NHQO.SORT_BY_TITLE_ASCENDING) { + newSort = NHQO.SORT_BY_TITLE_DESCENDING; + } else if (allowTriState && oldSort == NHQO.SORT_BY_TITLE_DESCENDING) { + newSort = NHQO.SORT_BY_NONE; + } else { + newSort = NHQO.SORT_BY_TITLE_ASCENDING; + } + + break; + case this.COLUMN_TYPE_URI: + if (oldSort == NHQO.SORT_BY_URI_ASCENDING) { + newSort = NHQO.SORT_BY_URI_DESCENDING; + } else if (allowTriState && oldSort == NHQO.SORT_BY_URI_DESCENDING) { + newSort = NHQO.SORT_BY_NONE; + } else { + newSort = NHQO.SORT_BY_URI_ASCENDING; + } + + break; + case this.COLUMN_TYPE_DATE: + if (oldSort == NHQO.SORT_BY_DATE_ASCENDING) { + newSort = NHQO.SORT_BY_DATE_DESCENDING; + } else if (allowTriState && oldSort == NHQO.SORT_BY_DATE_DESCENDING) { + newSort = NHQO.SORT_BY_NONE; + } else { + newSort = NHQO.SORT_BY_DATE_ASCENDING; + } + + break; + case this.COLUMN_TYPE_VISITCOUNT: + // visit count default is unusual because we sort by descending + // by default because you are most likely to be looking for + // highly visited sites when you click it + if (oldSort == NHQO.SORT_BY_VISITCOUNT_DESCENDING) { + newSort = NHQO.SORT_BY_VISITCOUNT_ASCENDING; + } else if ( + allowTriState && + oldSort == NHQO.SORT_BY_VISITCOUNT_ASCENDING + ) { + newSort = NHQO.SORT_BY_NONE; + } else { + newSort = NHQO.SORT_BY_VISITCOUNT_DESCENDING; + } + + break; + case this.COLUMN_TYPE_DATEADDED: + if (oldSort == NHQO.SORT_BY_DATEADDED_ASCENDING) { + newSort = NHQO.SORT_BY_DATEADDED_DESCENDING; + } else if ( + allowTriState && + oldSort == NHQO.SORT_BY_DATEADDED_DESCENDING + ) { + newSort = NHQO.SORT_BY_NONE; + } else { + newSort = NHQO.SORT_BY_DATEADDED_ASCENDING; + } + + break; + case this.COLUMN_TYPE_LASTMODIFIED: + if (oldSort == NHQO.SORT_BY_LASTMODIFIED_ASCENDING) { + newSort = NHQO.SORT_BY_LASTMODIFIED_DESCENDING; + } else if ( + allowTriState && + oldSort == NHQO.SORT_BY_LASTMODIFIED_DESCENDING + ) { + newSort = NHQO.SORT_BY_NONE; + } else { + newSort = NHQO.SORT_BY_LASTMODIFIED_ASCENDING; + } + + break; + case this.COLUMN_TYPE_TAGS: + if (oldSort == NHQO.SORT_BY_TAGS_ASCENDING) { + newSort = NHQO.SORT_BY_TAGS_DESCENDING; + } else if (allowTriState && oldSort == NHQO.SORT_BY_TAGS_DESCENDING) { + newSort = NHQO.SORT_BY_NONE; + } else { + newSort = NHQO.SORT_BY_TAGS_ASCENDING; + } + + break; + default: + throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG); + } + this._result.sortingMode = newSort; + }, + + isEditable: function PTV_isEditable(aRow, aColumn) { + // At this point we only support editing the title field. + if (aColumn.index != 0) { + return false; + } + + let node = this._rows[aRow]; + if (!node) { + console.error("isEditable called for an unbuilt row."); + return false; + } + let itemGuid = node.bookmarkGuid; + + // Only bookmark-nodes are editable. + if (!itemGuid) { + return false; + } + + // The following items are also not editable, even though they are bookmark + // items. + // * places-roots + // * the left pane special folders and queries (those are place: uri + // bookmarks) + // * separators + // + // Note that concrete itemIds aren't used intentionally. For example, we + // have no reason to disallow renaming a shortcut to the Bookmarks Toolbar, + // except for the one under All Bookmarks. + if ( + PlacesUtils.nodeIsSeparator(node) || + PlacesUtils.isRootItem(itemGuid) || + PlacesUtils.isQueryGeneratedFolder(node) + ) { + return false; + } + + return true; + }, + + setCellText: function PTV_setCellText(aRow, aColumn, aText) { + // We may only get here if the cell is editable. + let node = this._rows[aRow]; + if (node.title != aText) { + PlacesTransactions.EditTitle({ guid: node.bookmarkGuid, title: aText }) + .transact() + .catch(console.error); + } + }, + + toggleCutNode: function PTV_toggleCutNode(aNode, aValue) { + let currentVal = this._cuttingNodes.has(aNode); + if (currentVal != aValue) { + if (aValue) { + this._cuttingNodes.add(aNode); + } else { + this._cuttingNodes.delete(aNode); + } + + this._invalidateCellValue(aNode, this.COLUMN_TYPE_TITLE); + } + }, + + selectionChanged() {}, + cycleCell(aRow, aColumn) {}, +}; |