diff options
Diffstat (limited to 'application/basilisk/components/places/content/editBookmarkOverlay.js')
-rw-r--r-- | application/basilisk/components/places/content/editBookmarkOverlay.js | 1196 |
1 files changed, 1196 insertions, 0 deletions
diff --git a/application/basilisk/components/places/content/editBookmarkOverlay.js b/application/basilisk/components/places/content/editBookmarkOverlay.js new file mode 100644 index 000000000..d59f5c764 --- /dev/null +++ b/application/basilisk/components/places/content/editBookmarkOverlay.js @@ -0,0 +1,1196 @@ +/* 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/. */ + +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Task", + "resource://gre/modules/Task.jsm"); + +const LAST_USED_ANNO = "bookmarkPropertiesDialog/folderLastUsed"; +const MAX_FOLDER_ITEM_IN_MENU_LIST = 5; + +var gEditItemOverlay = { + _observersAdded: false, + _staticFoldersListBuilt: 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"); + + 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 = PlacesUIUtils.useAsyncTransactions && 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); + if (isTag) { + itemId = PlacesUtils.getConcreteItemId(node); + // For now we don't have access to the item guid synchronously for tags, + // so we'll need to fetch it later. + } + let isURI = node && PlacesUtils.nodeIsURI(node); + let uri = isURI ? NetUtil.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; + if (node && "parent" in node) { + let parent = node.parent; + if (parent) { + isParentReadOnly = !PlacesUtils.nodeIsFolder(parent) || + PlacesUIUtils.isContentsReadOnly(parent); + } + } + let focusedElement = aInitInfo.focusedElement; + let onPanelReady = aInitInfo.onPanelReady; + + return this._paneInfo = { itemId, itemGuid, isItem, + isURI, uri, title, + isBookmark, isFolderShortcut, isParentReadOnly, + bulkTagging, uris, + visibleRows, postData, isTag, focusedElement, + onPanelReady }; + }, + + get initialized() { + return this._paneInfo != null; + }, + + // Backwards-compatibility getters + get itemId() { + if (!this.initialized || 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); + }, + + // 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 || ""); + }, + + _initLocationField() { + if (!this._paneInfo.isURI) + throw new Error("_initLocationField called unexpectedly"); + this._initTextField(this._locationField, this._paneInfo.uri.spec); + }, + + _initDescriptionField() { + if (!this._paneInfo.isItem) + throw new Error("_initDescriptionField called unexpectedly"); + + this._initTextField(this._descriptionField, + PlacesUIUtils.getItemDescription(this._paneInfo.itemId)); + }, + + _initKeywordField: Task.async(function* (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 = []; + yield PlacesUtils.keywords.fetch({ url: this._paneInfo.uri.spec }, + e => entries.push(e)); + if (entries.length > 0) { + // 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); + } + } + } + }), + + _initLoadInSidebar: Task.async(function* () { + if (!this._paneInfo.isBookmark) + throw new Error("_initLoadInSidebar called unexpectedly"); + + this._loadInSidebarCheckbox.checked = + PlacesUtils.annotations.itemHasAnnotation( + this._paneInfo.itemId, PlacesUIUtils.LOAD_IN_SIDEBAR_ANNO); + }), + + /** + * Initialize the panel. + * + * @param aInfo + * An object having: + * 1. one of the following properties: + * - node: 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. + * - uris: an array of uris for bulk tagging. + * + * 2. any of the following optional properties: + * - hiddenRows (Strings array): list of rows to be hidden regardless + * of the item edited. Possible values: "title", "location", + * "description", "keyword", "loadInSidebar", "feedLocation", + * "siteLocation", folderPicker" + */ + 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); + + let { itemId, isItem, isURI, + isBookmark, bulkTagging, uris, + visibleRows, focusedElement, + onPanelReady } = this._setPaneInfo(aInfo); + + let showOrCollapse = + (rowId, isAppropriateForInput, nameInHiddenRows = null) => { + let visible = isAppropriateForInput; + if (visible && "hiddenRows" in aInfo && nameInHiddenRows) + visible &= aInfo.hiddenRows.indexOf(nameInHiddenRows) == -1; + if (visible) + visibleRows.add(rowId); + return !(this._element(rowId).collapsed = !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; + } + + // hide the description field for + if (showOrCollapse("descriptionRow", isItem && !this.readOnly, + "description")) { + this._initDescriptionField(); + this._descriptionField.readOnly = this.readOnly; + } + + if (showOrCollapse("keywordRow", isBookmark, "keyword")) { + this._initKeywordField().catch(Components.utils.reportError); + this._keywordField.readOnly = this.readOnly; + } + + // Collapse the tag selector if the item does not accept tags. + if (showOrCollapse("tagsRow", isURI || bulkTagging, "tags")) + this._initTagsField().catch(Components.utils.reportError); + else if (!this._element("tagsSelectorRow").collapsed) + this.toggleTagsSelector().catch(Components.utils.reportError); + + // Load in sidebar. + if (showOrCollapse("loadInSidebarCheckbox", isBookmark, "loadInSidebar")) { + this._initLoadInSidebar(); + } + + // 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")) { + let containerId = PlacesUtils.bookmarks.getFolderIdForItem(itemId); + this._initFolderMenuList(containerId); + } + + // 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, false); + window.addEventListener("unload", this, false); + 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 collapsed textbox + // Note: since all controls are collapsed by default, we don't get the + // default XUL dialog behavior, that selects the first control, so we set + // the focus explicitly. + // Note: If focusedElement === "preferred", this file expects gPrefService + // to be defined in the global scope. + let elt; + if (focusedElement === "preferred") { + /* eslint-disable no-undef */ + elt = this._element(gPrefService.getCharPref("browser.bookmarks.editDialog.firstEditField")); + /* eslint-enable no-undef */ + } else if (focusedElement === "first") { + elt = document.querySelector("textbox:not([collapsed=true])"); + } + if (elt) { + elt.focus(); + elt.select(); + } + }; + + if (onPanelReady) { + onPanelReady(focusElement); + } else { + focusElement(); + } + }, + + /** + * Finds tags that are in common among this._currentInfo.uris; + */ + _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 + let transactionManager; + try { + transactionManager = aElement.editor.transactionManager; + } catch (e) { + // When retrieving the transaction manager, editor may be null resulting + // in a TypeError. Additionally, the transaction manager may not + // exist yet, which causes access to it to throw NS_ERROR_FAILURE. + // In either event, the transaction manager doesn't exist it, so we + // don't need to worry about clearing it. + if (!(e instanceof TypeError) && e.result != Cr.NS_ERROR_FAILURE) { + throw e; + } + } + if (transactionManager) { + transactionManager.clear(); + } + } + }, + + /** + * Appends a menu-item representing a bookmarks folder to a menu-popup. + * @param aMenupopup + * The popup to which the menu-item should be added. + * @param aFolderId + * The identifier of the bookmarks folder. + * @return the new menu item. + */ + _appendFolderItemToMenupopup(aMenupopup, aFolderId) { + // First make sure the folders-separator is visible + this._element("foldersSeparator").hidden = false; + + var folderMenuItem = document.createElement("menuitem"); + var folderTitle = PlacesUtils.bookmarks.getItemTitle(aFolderId) + folderMenuItem.folderId = aFolderId; + folderMenuItem.setAttribute("label", folderTitle); + folderMenuItem.className = "menuitem-iconic folder-icon"; + aMenupopup.appendChild(folderMenuItem); + return folderMenuItem; + }, + + _initFolderMenuList: function EIO__initFolderMenuList(aSelectedFolder) { + // clean up first + var menupopup = this._folderMenuList.menupopup; + while (menupopup.childNodes.length > 6) + menupopup.removeChild(menupopup.lastChild); + + const bms = PlacesUtils.bookmarks; + const annos = PlacesUtils.annotations; + + // Build the static list + var unfiledItem = this._element("unfiledRootItem"); + if (!this._staticFoldersListBuilt) { + unfiledItem.label = bms.getItemTitle(PlacesUtils.unfiledBookmarksFolderId); + unfiledItem.folderId = PlacesUtils.unfiledBookmarksFolderId; + var bmMenuItem = this._element("bmRootItem"); + bmMenuItem.label = bms.getItemTitle(PlacesUtils.bookmarksMenuFolderId); + bmMenuItem.folderId = PlacesUtils.bookmarksMenuFolderId; + var toolbarItem = this._element("toolbarFolderItem"); + toolbarItem.label = bms.getItemTitle(PlacesUtils.toolbarFolderId); + toolbarItem.folderId = PlacesUtils.toolbarFolderId; + this._staticFoldersListBuilt = true; + } + + // List of recently used folders: + var folderIds = annos.getItemsWithAnnotation(LAST_USED_ANNO); + + /** + * The value of the LAST_USED_ANNO annotation is the time (in the form of + * Date.getTime) at which the folder has been last used. + * + * 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 i = 0; i < folderIds.length; i++) { + var lastUsed = annos.getItemAnnotation(folderIds[i], LAST_USED_ANNO); + this._recentFolders.push({ folderId: folderIds[i], lastUsed: lastUsed }); + } + this._recentFolders.sort(function(a, b) { + if (b.lastUsed < a.lastUsed) + return -1; + if (b.lastUsed > a.lastUsed) + return 1; + return 0; + }); + + var numberOfItems = Math.min(MAX_FOLDER_ITEM_IN_MENU_LIST, + this._recentFolders.length); + for (let i = 0; i < numberOfItems; i++) { + this._appendFolderItemToMenupopup(menupopup, + this._recentFolders[i].folderId); + } + + var defaultItem = this._getFolderMenuItem(aSelectedFolder); + this._folderMenuList.selectedItem = defaultItem; + + // Set a selectedIndex attribute to show special icons + this._folderMenuList.setAttribute("selectedIndex", + this._folderMenuList.selectedIndex); + + // Hide the folders-separator if no folder is annotated as recently-used + this._element("foldersSeparator").hidden = (menupopup.childNodes.length <= 6); + this._folderMenuList.disabled = this.readOnly; + }, + + QueryInterface: + XPCOMUtils.generateQI([Components.interfaces.nsIDOMEventListener, + Components.interfaces.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.collapsed) + this.toggleFolderTreeVisibility(); + + // Hide the tag selector if it was previously visible. + var tagsSelectorRow = this._element("tagsSelectorRow"); + if (!tagsSelectorRow.collapsed) + this.toggleTagsSelector(); + } + + if (this._observersAdded) { + PlacesUtils.bookmarks.removeObserver(this); + this._observersAdded = false; + } + + this._setPaneInfo(null); + this._firstEditedField = ""; + }, + + onTagsFieldChange() { + if (this._paneInfo.isURI || this._paneInfo.bulkTagging) { + this._updateTags().then( + anyChanges => { + if (anyChanges) + this._mayUpdateFirstEditField("tagsField"); + }, Components.utils.reportError); + } + }, + + /** + * For a given array of currently-set tags and the tags-input-field + * value, returns which tags should be removed and which should be added in + * the form of { removedTags: [...], newTags: [...] }. + */ + _getTagsChanges(aCurrentTags) { + let inputTags = this._getTagsArrayFromTagsInputField(); + + // Optimize the trivial cases (which are actually the most common). + if (inputTags.length == 0 && aCurrentTags.length == 0) + return { newTags: [], removedTags: [] }; + if (inputTags.length == 0) + return { newTags: [], removedTags: aCurrentTags }; + if (aCurrentTags.length == 0) + return { newTags: inputTags, removedTags: [] }; + + let removedTags = aCurrentTags.filter(t => !inputTags.includes(t)); + let newTags = inputTags.filter(t => !aCurrentTags.includes(t)); + return { removedTags, newTags }; + }, + + // Adds and removes tags for one or more uris. + _setTagsFromTagsInputField: Task.async(function* (aCurrentTags, aURIs) { + let { removedTags, newTags } = this._getTagsChanges(aCurrentTags); + if (removedTags.length + newTags.length == 0) + return false; + + if (!PlacesUIUtils.useAsyncTransactions) { + let txns = []; + for (let uri of aURIs) { + if (removedTags.length > 0) + txns.push(new PlacesUntagURITransaction(uri, removedTags)); + if (newTags.length > 0) + txns.push(new PlacesTagURITransaction(uri, newTags)); + } + + PlacesUtils.transactionManager.doTransaction( + new PlacesAggregatedTransaction("Update tags", txns)); + return true; + } + + let setTags = function* () { + if (newTags.length > 0) { + yield PlacesTransactions.Tag({ urls: aURIs, tags: newTags }) + .transact(); + } + if (removedTags.length > 0) { + yield PlacesTransactions.Untag({ urls: aURIs, tags: removedTags }) + .transact(); + } + }; + + // 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).catch(Components.utils.reportError); + else + Task.spawn(setTags).catch(Components.utils.reportError); + return true; + }), + + _updateTags: Task.async(function*() { + let uris = this._paneInfo.bulkTagging ? + this._paneInfo.uris : [this._paneInfo.uri]; + let currentTags = this._paneInfo.bulkTagging ? + yield this._getCommonTags() : + PlacesUtils.tagging.getTagsForURI(uris[0]); + let anyChanges = yield 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 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 + var prefs = Cc["@mozilla.org/preferences-service;1"]. + getService(Ci.nsIPrefBranch); + prefs.setCharPref("browser.bookmarks.editDialog.firstEditField", aNewField); + }, + + onNamePickerChange() { + if (this.readOnly || !(this._paneInfo.isItem || this._paneInfo.isTag)) + return; + + // Here we update either the item title or its cached static title + let newTitle = this._namePicker.value; + if (!newTitle && + PlacesUtils.bookmarks.getFolderIdForItem(this._paneInfo.itemId) == PlacesUtils.tagsFolderId) { + // We don't allow setting an empty title for a tag, restore the old one. + this._initNamePicker(); + } + else { + this._mayUpdateFirstEditField("namePicker"); + if (!PlacesUIUtils.useAsyncTransactions) { + let txn = new PlacesEditItemTitleTransaction(this._paneInfo.itemId, + newTitle); + PlacesUtils.transactionManager.doTransaction(txn); + return; + } + Task.spawn(function* () { + let guid = this._paneInfo.isTag + ? (yield PlacesUtils.promiseItemGuid(this._paneInfo.itemId)) + : this._paneInfo.itemGuid; + PlacesTransactions.EditTitle({ guid, title: newTitle }) + .transact().catch(Components.utils.reportError); + }).catch(Components.utils.reportError); + } + }, + + onDescriptionFieldChange() { + if (this.readOnly || !this._paneInfo.isItem) + return; + + let itemId = this._paneInfo.itemId; + let description = this._element("descriptionField").value; + if (description != PlacesUIUtils.getItemDescription(this._paneInfo.itemId)) { + let annotation = + { name: PlacesUIUtils.DESCRIPTION_ANNO, value: description }; + if (!PlacesUIUtils.useAsyncTransactions) { + let txn = new PlacesSetItemAnnotationTransaction(itemId, + annotation); + PlacesUtils.transactionManager.doTransaction(txn); + return; + } + let guid = this._paneInfo.itemGuid; + PlacesTransactions.Annotate({ guid, annotation }) + .transact().catch(Components.utils.reportError); + } + }, + + onLocationFieldChange() { + if (this.readOnly || !this._paneInfo.isBookmark) + return; + + let newURI; + try { + newURI = PlacesUIUtils.createFixedURI(this._locationField.value); + } + catch (ex) { + // TODO: Bug 1089141 - Provide some feedback about the invalid url. + return; + } + + if (this._paneInfo.uri.equals(newURI)) + return; + + if (!PlacesUIUtils.useAsyncTransactions) { + let txn = new PlacesEditBookmarkURITransaction(this._paneInfo.itemId, newURI); + PlacesUtils.transactionManager.doTransaction(txn); + return; + } + let guid = this._paneInfo.itemGuid; + PlacesTransactions.EditUrl({ guid, url: newURI }) + .transact().catch(Components.utils.reportError); + }, + + onKeywordFieldChange() { + if (this.readOnly || !this._paneInfo.isBookmark) + return; + + let itemId = this._paneInfo.itemId; + let oldKeyword = this._keyword; + let keyword = this._keyword = this._keywordField.value; + let postData = this._paneInfo.postData; + if (!PlacesUIUtils.useAsyncTransactions) { + let txn = new PlacesEditBookmarkKeywordTransaction(itemId, + keyword, + postData, + oldKeyword); + PlacesUtils.transactionManager.doTransaction(txn); + return; + } + let guid = this._paneInfo.itemGuid; + PlacesTransactions.EditKeyword({ guid, keyword, postData, oldKeyword }) + .transact().catch(Components.utils.reportError); + }, + + onLoadInSidebarCheckboxCommand() { + if (!this.initialized || !this._paneInfo.isBookmark) + return; + + let annotation = { name : PlacesUIUtils.LOAD_IN_SIDEBAR_ANNO }; + if (this._loadInSidebarCheckbox.checked) + annotation.value = true; + + if (!PlacesUIUtils.useAsyncTransactions) { + let itemId = this._paneInfo.itemId; + let txn = new PlacesSetItemAnnotationTransaction(itemId, + annotation); + PlacesUtils.transactionManager.doTransaction(txn); + return; + } + let guid = this._paneInfo.itemGuid; + PlacesTransactions.Annotate({ guid, annotation }) + .transact().catch(Components.utils.reportError); + }, + + toggleFolderTreeVisibility() { + var expander = this._element("foldersExpander"); + var folderTreeRow = this._element("folderTreeRow"); + if (!folderTreeRow.collapsed) { + expander.className = "expander-down"; + expander.setAttribute("tooltiptext", + expander.getAttribute("tooltiptextdown")); + folderTreeRow.collapsed = true; + this._element("chooseFolderSeparator").hidden = + this._element("chooseFolderMenuItem").hidden = false; + } + else { + expander.className = "expander-up" + expander.setAttribute("tooltiptext", + expander.getAttribute("tooltiptextup")); + folderTreeRow.collapsed = false; + + // XXXmano: Ideally we would only do this once, but for some odd reason, + // the editable mode set on this tree, together with its collapsed state + // breaks the view. + const FOLDER_TREE_PLACE_URI = + "place:excludeItems=1&excludeQueries=1&excludeReadOnlyFolders=1&folder=" + + PlacesUIUtils.allBookmarksFolderId; + this._folderTree.place = FOLDER_TREE_PLACE_URI; + + this._element("chooseFolderSeparator").hidden = + this._element("chooseFolderMenuItem").hidden = true; + var currentFolder = this._getFolderIdFromMenuList(); + this._folderTree.selectItems([currentFolder]); + this._folderTree.focus(); + } + }, + + _getFolderIdFromMenuList() { + var selectedItem = this._folderMenuList.selectedItem; + NS_ASSERT("folderId" in selectedItem, + "Invalid menuitem in the folders-menulist"); + return selectedItem.folderId; + }, + + /** + * 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 MAX_FOLDERS_IN_MENU_LIST) is reached, + * the new item replaces the last menu-item. + * @param aFolderId + * The identifier of the bookmarks folder. + */ + _getFolderMenuItem(aFolderId) { + let menupopup = this._folderMenuList.menupopup; + let menuItem = Array.prototype.find.call( + menupopup.childNodes, menuItem => menuItem.folderId === aFolderId); + if (menuItem !== undefined) + return menuItem; + + // 3 special folders + separator + folder-items-count limit + if (menupopup.childNodes.length == 4 + MAX_FOLDER_ITEM_IN_MENU_LIST) + menupopup.removeChild(menupopup.lastChild); + + return this._appendFolderItemToMenupopup(menupopup, aFolderId); + }, + + onFolderMenuListCommand(aEvent) { + // Set a selectedIndex attribute to show special icons + this._folderMenuList.setAttribute("selectedIndex", + this._folderMenuList.selectedIndex); + + 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 containerId = PlacesUtils.bookmarks.getFolderIdForItem(this._paneInfo.itemId); + let item = this._getFolderMenuItem(containerId); + 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 containerId = this._getFolderIdFromMenuList(); + if (PlacesUtils.bookmarks.getFolderIdForItem(this._paneInfo.itemId) != containerId && + this._paneInfo.itemId != containerId) { + if (PlacesUIUtils.useAsyncTransactions) { + Task.spawn(function* () { + let newParentGuid = yield PlacesUtils.promiseItemGuid(containerId); + let guid = this._paneInfo.itemGuid; + yield PlacesTransactions.Move({ guid, newParentGuid }).transact(); + }.bind(this)); + } + else { + let txn = new PlacesMoveItemTransaction(this._paneInfo.itemId, + containerId, + PlacesUtils.bookmarks.DEFAULT_INDEX); + PlacesUtils.transactionManager.doTransaction(txn); + } + + // Mark the containing folder as recently-used if it isn't in the + // static list + if (containerId != PlacesUtils.unfiledBookmarksFolderId && + containerId != PlacesUtils.toolbarFolderId && + containerId != PlacesUtils.bookmarksMenuFolderId) { + this._markFolderAsRecentlyUsed(containerId) + .catch(Components.utils.reportError); + } + + // Auto-show the bookmarks toolbar when adding / moving an item there. + if (containerId == PlacesUtils.toolbarFolderId) { + Services.obs.notifyObservers(null, "autoshow-bookmarks-toolbar", null); + } + } + + // Update folder-tree selection + var folderTreeRow = this._element("folderTreeRow"); + if (!folderTreeRow.collapsed) { + var selectedNode = this._folderTree.selectedNode; + if (!selectedNode || + PlacesUtils.getConcreteItemId(selectedNode) != containerId) + this._folderTree.selectItems([containerId]); + } + }, + + onFolderTreeSelect() { + 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 folderId = PlacesUtils.getConcreteItemId(selectedNode); + if (this._getFolderIdFromMenuList() == folderId) + return; + + var folderItem = this._getFolderMenuItem(folderId); + this._folderMenuList.selectedItem = folderItem; + folderItem.doCommand(); + }, + + _markFolderAsRecentlyUsed: Task.async(function* (aFolderId) { + if (!PlacesUIUtils.useAsyncTransactions) { + let txns = []; + + // Expire old unused recent folders. + let annotation = this._getLastUsedAnnotationObject(false); + while (this._recentFolders.length > MAX_FOLDER_ITEM_IN_MENU_LIST) { + let folderId = this._recentFolders.pop().folderId; + let annoTxn = new PlacesSetItemAnnotationTransaction(folderId, + annotation); + txns.push(annoTxn); + } + + // Mark folder as recently used + annotation = this._getLastUsedAnnotationObject(true); + let annoTxn = new PlacesSetItemAnnotationTransaction(aFolderId, + annotation); + txns.push(annoTxn); + + let aggregate = + new PlacesAggregatedTransaction("Update last used folders", txns); + PlacesUtils.transactionManager.doTransaction(aggregate); + return; + } + + // Expire old unused recent folders. + let guids = []; + while (this._recentFolders.length > MAX_FOLDER_ITEM_IN_MENU_LIST) { + let folderId = this._recentFolders.pop().folderId; + let guid = yield PlacesUtils.promiseItemGuid(folderId); + guids.push(guid); + } + if (guids.length > 0) { + let annotation = this._getLastUsedAnnotationObject(false); + PlacesTransactions.Annotate({ guids, annotation }) + .transact().catch(Components.utils.reportError); + } + + // Mark folder as recently used + let annotation = this._getLastUsedAnnotationObject(true); + let guid = yield PlacesUtils.promiseItemGuid(aFolderId); + PlacesTransactions.Annotate({ guid, annotation }) + .transact().catch(Components.utils.reportError); + }), + + /** + * Returns an object which could then be used to set/unset the + * LAST_USED_ANNO annotation for a folder. + * + * @param aLastUsed + * Whether to set or unset the LAST_USED_ANNO annotation. + * @returns an object representing the annotation which could then be used + * with the transaction manager. + */ + _getLastUsedAnnotationObject(aLastUsed) { + return { name: LAST_USED_ANNO, + value: aLastUsed ? new Date().getTime() : null }; + }, + + _rebuildTagsSelectorList: Task.async(function* () { + let tagsSelector = this._element("tagsSelector"); + let tagsSelectorRow = this._element("tagsSelectorRow"); + if (tagsSelectorRow.collapsed) + return; + + // Save the current scroll position and restore it after the rebuild. + let firstIndex = tagsSelector.getIndexOfFirstVisibleRow(); + let selectedIndex = tagsSelector.selectedIndex; + let selectedTag = selectedIndex >= 0 ? tagsSelector.selectedItem.label + : null; + + while (tagsSelector.hasChildNodes()) { + tagsSelector.removeChild(tagsSelector.lastChild); + } + + let tagsInField = this._getTagsArrayFromTagsInputField(); + let allTags = PlacesUtils.tagging.allTags; + for (tag of allTags) { + let elt = document.createElement("listitem"); + elt.setAttribute("type", "checkbox"); + elt.setAttribute("label", tag); + if (tagsInField.includes(tag)) + elt.setAttribute("checked", "true"); + tagsSelector.appendChild(elt); + if (selectedTag === tag) + selectedIndex = tagsSelector.getIndexOfItem(elt); + } + + // Restore position. + // The listbox allows to scroll only if the required offset doesn't + // overflow its capacity, thus need to adjust the index for removals. + firstIndex = + Math.min(firstIndex, + tagsSelector.itemCount - tagsSelector.getNumberOfVisibleRows()); + tagsSelector.scrollToIndex(firstIndex); + if (selectedIndex >= 0 && tagsSelector.itemCount > 0) { + selectedIndex = Math.min(selectedIndex, tagsSelector.itemCount - 1); + tagsSelector.selectedIndex = selectedIndex; + tagsSelector.ensureIndexIsVisible(selectedIndex); + } + }), + + toggleTagsSelector: Task.async(function* () { + var tagsSelector = this._element("tagsSelector"); + var tagsSelectorRow = this._element("tagsSelectorRow"); + var expander = this._element("tagsSelectorExpander"); + if (tagsSelectorRow.collapsed) { + expander.className = "expander-up"; + expander.setAttribute("tooltiptext", + expander.getAttribute("tooltiptextup")); + tagsSelectorRow.collapsed = false; + yield this._rebuildTagsSelectorList(); + + // This is a no-op if we've added the listener. + tagsSelector.addEventListener("CheckboxStateChange", this, false); + } + else { + expander.className = "expander-down"; + expander.setAttribute("tooltiptext", + expander.getAttribute("tooltiptextdown")); + tagsSelectorRow.collapsed = true; + } + }), + + /** + * Splits "tagsField" element value, returning an array of valid tag strings. + * + * @return 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 > 0); // Kill empty tags. + }, + + newFolder: Task.async(function* () { + let ip = this._folderTree.insertionPoint; + + // default to the bookmarks menu folder + if (!ip || ip.itemId == PlacesUIUtils.allBookmarksFolderId) { + ip = new InsertionPoint(PlacesUtils.bookmarksMenuFolderId, + PlacesUtils.bookmarks.DEFAULT_INDEX, + Ci.nsITreeView.DROP_ON); + } + + // XXXmano: add a separate "New Folder" string at some point... + let title = this._element("newFolderButton").label; + if (PlacesUIUtils.useAsyncTransactions) { + let parentGuid = yield ip.promiseGuid(); + yield PlacesTransactions.NewFolder({ parentGuid, title, index: ip.index }) + .transact().catch(Components.utils.reportError); + } + else { + let txn = new PlacesCreateFolderTransaction(title, ip.itemId, ip.index); + PlacesUtils.transactionManager.doTransaction(txn); + } + + this._folderTree.focus(); + this._folderTree.selectItems([ip.itemId]); + PlacesUtils.asContainer(this._folderTree.selectedNode).containerOpen = true; + this._folderTree.selectItems([this._lastNewItem]); + this._folderTree.startEditing(this._folderTree.view.selection.currentIndex, + this._folderTree.columns.getFirstColumn()); + }), + + // nsIDOMEventListener + handleEvent(aEvent) { + switch (aEvent.type) { + case "CheckboxStateChange": + // Update the tags field when items are checked/unchecked in the listbox + let tags = this._getTagsArrayFromTagsInputField(); + let tagCheckbox = aEvent.target; + + let curTagIndex = tags.indexOf(tagCheckbox.label); + let tagsSelector = this._element("tagsSelector"); + tagsSelector.selectedItem = tagCheckbox; + + if (tagCheckbox.checked) { + if (curTagIndex == -1) + tags.push(tagCheckbox.label); + } + else if (curTagIndex != -1) { + tags.splice(curTagIndex, 1); + } + this._element("tagsField").value = tags.join(", "); + this._updateTags(); + break; + case "unload": + this.uninitPanel(false); + break; + } + }, + + _initTagsField: Task.async(function* () { + 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(", ")); + }), + + _onTagsChange(aItemId) { + let paneInfo = this._paneInfo; + let updateTagsField = false; + if (paneInfo.isURI) { + if (paneInfo.isBookmark && aItemId == paneInfo.itemId) { + updateTagsField = true; + } + else if (!paneInfo.isBookmark) { + let changedURI = PlacesUtils.bookmarks.getBookmarkURI(aItemId); + updateTagsField = changedURI.equals(paneInfo.uri); + } + } + else if (paneInfo.bulkTagging) { + let changedURI = PlacesUtils.bookmarks.getBookmarkURI(aItemId); + 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().catch(Components.utils.reportError); + + // Any tags change should be reflected in the tags selector. + if (this._element("tagsSelector")) + this._rebuildTagsSelectorList().catch(Components.utils.reportError); + }, + + _onItemTitleChange(aItemId, aNewTitle) { + if (!this._paneInfo.isBookmark) + return; + if (aItemId == this._paneInfo.itemId) { + 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 (menuitem of menupopup.childNodes) { + if ("folderId" in menuitem && menuitem.folderId == aItemId) { + menuitem.label = aNewTitle; + break; + } + } + } + }, + + // nsINavBookmarkObserver + onItemChanged(aItemId, aProperty, aIsAnnotationProperty, aValue, + aLastModified, aItemType) { + if (aProperty == "tags" && this._paneInfo.visibleRows.has("tagsRow")) { + this._onTagsChange(aItemId); + } + else if (aProperty == "title" && this._paneInfo.isItem) { + // This also updates titles of folders in the folder menu list. + this._onItemTitleChange(aItemId, aValue); + } + else if (!this._paneInfo.isItem || this._paneInfo.itemId != aItemId) { + return; + } + + switch (aProperty) { + case "uri": + let newURI = NetUtil.newURI(aValue); + 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(aItemId); + } + } + break; + case "keyword": + if (this._paneInfo.visibleRows.has("keywordRow")) + this._initKeywordField(aValue).catch(Components.utils.reportError); + break; + case PlacesUIUtils.DESCRIPTION_ANNO: + if (this._paneInfo.visibleRows.has("descriptionRow")) + this._initDescriptionField(); + break; + case PlacesUIUtils.LOAD_IN_SIDEBAR_ANNO: + if (this._paneInfo.visibleRows.has("loadInSidebarCheckbox")) + this._initLoadInSidebar(); + break; + } + }, + + onItemMoved(aItemId, aOldParent, aOldIndex, + aNewParent, aNewIndex, aItemType) { + if (!this._paneInfo.isItem || + !this._paneInfo.visibleRows.has("folderPicker") || + this._paneInfo.itemId != aItemOd || + aNewParent == this._getFolderIdFromMenuList()) { + return; + } + + // Just setting selectItem _does not_ trigger oncommand, so we don't + // recurse. + this._folderMenuList.selectedItem = this._getFolderMenuItem(aNewParent); + }, + + onItemAdded(aItemId, aParentId, aIndex, aItemType, aURI) { + this._lastNewItem = aItemId; + }, + + onItemRemoved() { }, + onBeginUpdateBatch() { }, + onEndUpdateBatch() { }, + onItemVisited() { }, +}; + + +for (let elt of ["folderMenuList", "folderTree", "namePicker", + "locationField", "descriptionField", "keywordField", + "tagsField", "loadInSidebarCheckbox"]) { + let eltScoped = elt; + XPCOMUtils.defineLazyGetter(gEditItemOverlay, `_${eltScoped}`, + () => gEditItemOverlay._element(eltScoped)); +} |