summaryrefslogtreecommitdiffstats
path: root/browser/components/places/content/instantEditBookmark.js
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/places/content/instantEditBookmark.js')
-rw-r--r--browser/components/places/content/instantEditBookmark.js1396
1 files changed, 1396 insertions, 0 deletions
diff --git a/browser/components/places/content/instantEditBookmark.js b/browser/components/places/content/instantEditBookmark.js
new file mode 100644
index 0000000000..067bb1eaaa
--- /dev/null
+++ b/browser/components/places/content/instantEditBookmark.js
@@ -0,0 +1,1396 @@
+/* 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 */
+/* import-globals-from controller.js */
+
+// This is defined in browser.js and only used in the star UI.
+/* global setToolbarVisibility */
+
+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",
+});
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CustomizableUI: "resource:///modules/CustomizableUI.jsm",
+});
+
+var gEditItemOverlay = {
+ // Array of PlacesTransactions accumulated by internal changes. It can be used
+ // to wait for completion.
+ transactionPromises: null,
+ _observersAdded: false,
+ _staticFoldersListBuilt: false,
+ _didChangeFolder: false,
+
+ _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 itemId = node ? node.itemId : -1;
+ let itemGuid = node ? PlacesUtils.getConcreteItemGuid(node) : null;
+ let isItem = itemId != -1;
+ 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 ||
+ (node.parent.itemId > 0 && !node.parent.bookmarkGuid)
+ ) {
+ throw new Error(
+ "Cannot use an incomplete node to initialize the edit bookmark panel"
+ );
+ }
+ let parent = node.parent;
+ isParentReadOnly = !PlacesUtils.nodeIsFolder(parent);
+ parentGuid = parent.bookmarkGuid;
+ }
+
+ let focusedElement = aInitInfo.focusedElement;
+ let onPanelReady = aInitInfo.onPanelReady;
+
+ return (this._paneInfo = {
+ itemId,
+ itemGuid,
+ parentGuid,
+ isItem,
+ isURI,
+ uri,
+ title,
+ isBookmark,
+ isFolderShortcut,
+ isParentReadOnly,
+ bulkTagging,
+ uris,
+ visibleRows,
+ postData,
+ isTag,
+ focusedElement,
+ onPanelReady,
+ tag,
+ });
+ },
+
+ get initialized() {
+ return this._paneInfo != null;
+ },
+
+ // Backwards-compatibility getters
+ get itemId() {
+ if (
+ !this.initialized ||
+ this._paneInfo.isTag ||
+ this._paneInfo.bulkTagging
+ ) {
+ return -1;
+ }
+ return this._paneInfo.itemId;
+ },
+
+ 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);
+ }
+ }
+ }
+ },
+
+ /**
+ * 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):
+ * itemId, 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) {
+ 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(Cu.reportError);
+ // 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", isURI || bulkTagging, "tags")) {
+ this._initTagsField();
+ } else if (!this._element("tagsSelectorRow").hidden) {
+ this.toggleTagsSelector().catch(Cu.reportError);
+ }
+
+ // 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(Cu.reportError);
+ if (instance != this._instance || this._paneInfo == null) {
+ return;
+ }
+ }
+
+ // Selection count.
+ if (showOrCollapse("selectionCount", bulkTagging)) {
+ this._element(
+ "itemsCountText"
+ ).value = PlacesUIUtils.getPluralString(
+ "detailsPane.itemsCountLabel",
+ uris.length,
+ [uris.length]
+ );
+ }
+
+ // Observe changes.
+ if (!this._observersAdded) {
+ PlacesUtils.bookmarks.addObserver(this);
+ this.handlePlacesEvents = this.handlePlacesEvents.bind(this);
+ PlacesUtils.observers.addListener(
+ [
+ "bookmark-moved",
+ "bookmark-tags-changed",
+ "bookmark-title-changed",
+ "bookmark-url-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"
+ )
+ );
+ } else if (focusedElement === "first") {
+ elt = document.querySelector('input:not([hidden="true"])');
+ }
+ if (elt) {
+ elt.focus({ preventScroll: true });
+ elt.select();
+ }
+ };
+
+ if (onPanelReady) {
+ onPanelReady(focusElement);
+ } else {
+ focusElement();
+ }
+ },
+
+ /**
+ * 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");
+ }
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsINavBookmarkObserver"]),
+
+ _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(Cu.reportError);
+ }
+ }
+
+ if (this._observersAdded) {
+ PlacesUtils.bookmarks.removeObserver(this);
+ PlacesUtils.observers.removeListener(
+ [
+ "bookmark-moved",
+ "bookmark-tags-changed",
+ "bookmark-title-changed",
+ "bookmark-url-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 = [];
+ },
+
+ get selectedFolderGuid() {
+ return (
+ this._folderMenuList.selectedItem &&
+ this._folderMenuList.selectedItem.folderGuid
+ );
+ },
+
+ 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)
+ ) {
+ this._updateTags().then(anyChanges => {
+ // Check _paneInfo here as we might be closing the dialog.
+ if (anyChanges && this._paneInfo) {
+ this._mayUpdateFirstEditField("tagsField");
+ }
+ }, Cu.reportError);
+ }
+ },
+
+ /**
+ * Works out the necessary changes for a given array of currently-set tags and
+ * the tags-input-field value.
+ *
+ * @param {string[]} aCurrentTags
+ * The tags to compare.
+ * @returns {object}
+ * Returns which tags should be removed and which should be added in
+ * the form of an object: `{ removedTags: [...], newTags: [...] }`.
+ */
+ _getTagsChanges(aCurrentTags) {
+ let inputTags = this._getTagsArrayFromTagsInputField();
+
+ // Optimize the trivial cases (which are actually the most common).
+ if (!inputTags.length && !aCurrentTags.length) {
+ return { newTags: [], removedTags: [] };
+ }
+ if (!inputTags.length) {
+ return { newTags: [], removedTags: aCurrentTags };
+ }
+ if (!aCurrentTags.length) {
+ return { newTags: inputTags, removedTags: [] };
+ }
+
+ // Do not remove tags that may be reinserted with a different
+ // case, since the tagging service may handle those more efficiently.
+ let lcInputTags = inputTags.map(t => t.toLowerCase());
+ let removedTags = aCurrentTags.filter(
+ t => !lcInputTags.includes(t.toLowerCase())
+ );
+ let newTags = inputTags.filter(t => !aCurrentTags.includes(t));
+ return { removedTags, newTags };
+ },
+
+ // Adds and removes tags for one or more uris.
+ _setTagsFromTagsInputField(aCurrentTags, aURIs) {
+ let { removedTags, newTags } = this._getTagsChanges(aCurrentTags);
+ if (removedTags.length + newTags.length == 0) {
+ return false;
+ }
+
+ let setTags = async () => {
+ let promises = [];
+ if (removedTags.length) {
+ let promise = PlacesTransactions.Untag({
+ urls: aURIs,
+ tags: removedTags,
+ })
+ .transact()
+ .catch(Cu.reportError);
+ this.transactionPromises.push(promise);
+ promises.push(promise);
+ }
+ if (newTags.length) {
+ let promise = PlacesTransactions.Tag({ urls: aURIs, tags: newTags })
+ .transact()
+ .catch(Cu.reportError);
+ this.transactionPromises.push(promise);
+ promises.push(promise);
+ }
+ // Don't use Promise.all because we want these to be executed in order.
+ for (let promise of promises) {
+ await promise;
+ }
+ };
+
+ // Only in the library info-pane it's safe (and necessary) to batch these.
+ // TODO bug 1093030: cleanup this mess when the bookmarksProperties dialog
+ // and star UI code don't "run a batch in the background".
+ if (window.document.documentElement.id == "places") {
+ PlacesTransactions.batch(setTags);
+ } else {
+ setTags();
+ }
+ return true;
+ },
+
+ async _updateTags() {
+ let uris = this._paneInfo.bulkTagging
+ ? this._paneInfo.uris
+ : [this._paneInfo.uri];
+ let currentTags = this._paneInfo.bulkTagging
+ ? await this._getCommonTags()
+ : PlacesUtils.tagging.getTagsForURI(uris[0]);
+ let anyChanges = this._setTagsFromTagsInputField(currentTags, uris);
+ if (!anyChanges) {
+ return false;
+ }
+
+ // The panel could have been closed in the meanwhile.
+ if (!this._paneInfo) {
+ return false;
+ }
+
+ // Ensure the tagsField is in sync, clean it up from empty tags
+ currentTags = this._paneInfo.bulkTagging
+ ? this._getCommonTags()
+ : PlacesUtils.tagging.getTagsForURI(this._paneInfo.uri);
+ this._initTextField(this._tagsField, currentTags.join(", "), false);
+ return true;
+ },
+
+ /**
+ * 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;
+ }
+
+ // 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;
+ }
+ // Get all the bookmarks for the old tag, tag them with the new tag, and
+ // untag them from the old tag.
+ let oldTag = this._paneInfo.tag;
+ this._paneInfo.tag = tag;
+ let title = this._paneInfo.title;
+ if (title == oldTag) {
+ this._paneInfo.title = tag;
+ }
+ let promise = PlacesTransactions.RenameTag({ oldTag, tag }).transact();
+ this.transactionPromises.push(promise.catch(Cu.reportError));
+ await promise;
+ return;
+ }
+
+ this._mayUpdateFirstEditField("namePicker");
+ let promise = PlacesTransactions.EditTitle({
+ guid: this._paneInfo.itemGuid,
+ title: this._namePicker.value,
+ }).transact();
+ this.transactionPromises.push(promise.catch(Cu.reportError));
+ await promise;
+ },
+
+ onLocationFieldChange() {
+ if (this.readOnly || !this._paneInfo.isBookmark) {
+ return;
+ }
+
+ 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;
+ }
+
+ let guid = this._paneInfo.itemGuid;
+ this.transactionPromises.push(
+ PlacesTransactions.EditUrl({ guid, url: newURI })
+ .transact()
+ .catch(Cu.reportError)
+ );
+ },
+
+ onKeywordFieldChange() {
+ if (this.readOnly || !this._paneInfo.isBookmark) {
+ return;
+ }
+
+ let oldKeyword = this._keyword;
+ let keyword = (this._keyword = this._keywordField.value);
+ let postData = this._paneInfo.postData;
+ let guid = this._paneInfo.itemGuid;
+ this.transactionPromises.push(
+ PlacesTransactions.EditKeyword({ guid, keyword, postData, oldKeyword })
+ .transact()
+ .catch(Cu.reportError)
+ );
+ },
+
+ 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) {
+ expander.setAttribute(
+ "tooltiptext",
+ expander.getAttribute("tooltiptextdown")
+ );
+ 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 {
+ expander.setAttribute(
+ "tooltiptext",
+ expander.getAttribute("tooltiptextup")
+ );
+ 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._paneInfo.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._paneInfo.parentGuid,
+ this._paneInfo.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._paneInfo.parentGuid != containerGuid &&
+ this._paneInfo.itemGuid != containerGuid
+ ) {
+ let promise = PlacesTransactions.Move({
+ guid: this._paneInfo.itemGuid,
+ newParentGuid: containerGuid,
+ }).transact();
+ this.transactionPromises.push(promise.catch(Cu.reportError));
+ await promise;
+
+ // 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 allTags = await PlacesUtils.bookmarks.fetchTags();
+ let fragment = document.createDocumentFragment();
+ for (let i = 0; i < allTags.length; i++) {
+ let tag = allTags[i].name;
+ 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) {
+ expander.setAttribute(
+ "tooltiptext",
+ expander.getAttribute("tooltiptextup")
+ );
+ 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 {
+ expander.setAttribute(
+ "tooltiptext",
+ expander.getAttribute("tooltiptextdown")
+ );
+ 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(Cu.reportError));
+ 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-moved":
+ if (!this._paneInfo.isItem || this._paneInfo.itemId != event.id) {
+ return;
+ }
+
+ this._paneInfo.parentGuid = event.parentGuid;
+
+ if (
+ !this._paneInfo.visibleRows.has("folderRow") ||
+ event.parentGuid === this._folderMenuList.selectedItem.folderGuid
+ ) {
+ return;
+ }
+
+ // Just setting selectItem _does not_ trigger oncommand, so we don't
+ // recurse.
+ const bm = await PlacesUtils.bookmarks.fetch(event.parentGuid);
+ this._folderMenuList.selectedItem = this._getFolderMenuItem(
+ event.parentGuid,
+ bm.title
+ );
+ break;
+ case "bookmark-tags-changed":
+ if (this._paneInfo.visibleRows.has("tagsRow")) {
+ this._onTagsChange(event.guid).catch(Cu.reportError);
+ }
+ break;
+ 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;
+ case "bookmark-url-changed":
+ if (!this._paneInfo.isItem || this._paneInfo.itemId != event.id) {
+ return;
+ }
+
+ const newURI = Services.io.newURI(event.url);
+ if (!newURI.equals(this._paneInfo.uri)) {
+ this._paneInfo.uri = newURI;
+ if (this._paneInfo.visibleRows.has("locationRow")) {
+ this._initLocationField();
+ }
+
+ if (this._paneInfo.visibleRows.has("tagsRow")) {
+ delete this._paneInfo._cachedCommonTags;
+ this._onTagsChange(event.guid, newURI).catch(Cu.reportError);
+ }
+ }
+ 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(", "));
+ },
+
+ async _onTagsChange(guid, changedURI = null) {
+ let paneInfo = this._paneInfo;
+ let updateTagsField = false;
+ if (paneInfo.isURI) {
+ if (paneInfo.isBookmark && guid == paneInfo.itemGuid) {
+ updateTagsField = true;
+ } else if (!paneInfo.isBookmark) {
+ if (!changedURI) {
+ let href = (await PlacesUtils.bookmarks.fetch(guid)).url.href;
+ changedURI = Services.io.newURI(href);
+ }
+ updateTagsField = changedURI.equals(paneInfo.uri);
+ }
+ } else if (paneInfo.bulkTagging) {
+ if (!changedURI) {
+ let href = (await PlacesUtils.bookmarks.fetch(guid)).url.href;
+ changedURI = Services.io.newURI(href);
+ }
+ if (paneInfo.uris.some(uri => uri.equals(changedURI))) {
+ updateTagsField = true;
+ delete this._paneInfo._cachedCommonTags;
+ }
+ } else {
+ throw new Error("_onTagsChange called unexpectedly");
+ }
+
+ if (updateTagsField) {
+ this._initTagsField();
+ // Any tags change should be reflected in the tags selector.
+ if (this._element("tagsSelector")) {
+ await this._rebuildTagsSelectorList();
+ }
+ }
+ },
+
+ _onItemTitleChange(aItemId, aNewTitle, aGuid) {
+ if (aItemId == this._paneInfo.itemId || aGuid == this._paneInfo.itemGuid) {
+ this._paneInfo.title = aNewTitle;
+ this._initTextField(this._namePicker, aNewTitle);
+ } else 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;
+ }
+ }
+ }
+ },
+
+ // nsINavBookmarkObserver
+ onItemChanged(
+ aItemId,
+ aProperty,
+ aIsAnnotationProperty,
+ aValue,
+ aLastModified,
+ aItemType,
+ aParentId,
+ aGuid
+ ) {
+ if (!this._paneInfo.isItem || this._paneInfo.itemId != aItemId) {
+ return;
+ }
+
+ switch (aProperty) {
+ case "keyword":
+ if (this._paneInfo.visibleRows.has("keywordRow")) {
+ this._initKeywordField(aValue).catch(Cu.reportError);
+ }
+ break;
+ }
+ },
+
+ /**
+ * Flag which signals to consumers that this script is loaded, thus instant
+ * apply logic should be used.
+ *
+ * @returns {boolean} Always false.
+ */
+ get delayedApplyEnabled() {
+ return false;
+ },
+};
+
+XPCOMUtils.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"
+ flex="1"
+ class="placesTree"
+ is="places-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;
+ XPCOMUtils.defineLazyGetter(gEditItemOverlay, `_${eltScoped}`, () =>
+ gEditItemOverlay._element(eltScoped)
+ );
+}