tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

editBookmark.js (42778B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this file,
      3 * You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 /* global MozXULElement */
      6 
      7 // This is defined in browser.js and only used in the star UI.
      8 /* global setToolbarVisibility */
      9 
     10 /* import-globals-from controller.js */
     11 
     12 var { XPCOMUtils } = ChromeUtils.importESModule(
     13  "resource://gre/modules/XPCOMUtils.sys.mjs"
     14 );
     15 
     16 ChromeUtils.defineESModuleGetters(this, {
     17  CustomizableUI:
     18    "moz-src:///browser/components/customizableui/CustomizableUI.sys.mjs",
     19  PlacesTransactions: "resource://gre/modules/PlacesTransactions.sys.mjs",
     20  PlacesUIUtils: "moz-src:///browser/components/places/PlacesUIUtils.sys.mjs",
     21  PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
     22 });
     23 
     24 var gEditItemOverlay = {
     25  // Array of PlacesTransactions accumulated by internal changes. It can be used
     26  // to wait for completion.
     27  transactionPromises: null,
     28  _staticFoldersListBuilt: false,
     29  _didChangeFolder: false,
     30  // Tracks bookmark properties changes in the dialog, allowing external consumers
     31  // to either confirm or discard them.
     32  _bookmarkState: null,
     33  _allTags: null,
     34  _initPanelDeferred: null,
     35  _updateTagsDeferred: null,
     36  _paneInfo: null,
     37  _setPaneInfo(aInitInfo) {
     38    if (!aInitInfo) {
     39      return (this._paneInfo = null);
     40    }
     41 
     42    if ("uris" in aInitInfo && "node" in aInitInfo) {
     43      throw new Error("ambiguous pane info");
     44    }
     45    if (!("uris" in aInitInfo) && !("node" in aInitInfo)) {
     46      throw new Error("Neither node nor uris set for pane info");
     47    }
     48 
     49    // We either pass a node or uris.
     50    let node = "node" in aInitInfo ? aInitInfo.node : null;
     51 
     52    // Since there's no true UI for folder shortcuts (they show up just as their target
     53    // folders), when the pane shows for them it's opened in read-only mode, showing the
     54    // properties of the target folder.
     55    let itemGuid = node ? PlacesUtils.getConcreteItemGuid(node) : null;
     56    let isItem = !!itemGuid;
     57    let isFolderShortcut =
     58      isItem &&
     59      node.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT;
     60    let isTag = node && PlacesUtils.nodeIsTagQuery(node);
     61    let tag = null;
     62    if (isTag) {
     63      tag =
     64        PlacesUtils.asQuery(node).query.tags.length == 1
     65          ? node.query.tags[0]
     66          : node.title;
     67    }
     68 
     69    let isURI = node && PlacesUtils.nodeIsURI(node);
     70    let uri = isURI || isTag ? Services.io.newURI(node.uri) : null;
     71    let title = node ? node.title : null;
     72    let isBookmark = isItem && isURI;
     73 
     74    let addedMultipleBookmarks = aInitInfo.addedMultipleBookmarks;
     75    let bulkTagging = false;
     76    let uris = null;
     77    if (!node) {
     78      bulkTagging = true;
     79      uris = aInitInfo.uris;
     80    } else if (addedMultipleBookmarks) {
     81      bulkTagging = true;
     82      uris = node.children.map(c => c.url);
     83    }
     84 
     85    let visibleRows = new Set();
     86    let isParentReadOnly = false;
     87    let postData = aInitInfo.postData;
     88    let parentGuid = null;
     89 
     90    if (node && isItem) {
     91      if (!node.parent) {
     92        throw new Error(
     93          "Cannot use an incomplete node to initialize the edit bookmark panel"
     94        );
     95      }
     96      let parent = node.parent;
     97      isParentReadOnly = !PlacesUtils.nodeIsFolderOrShortcut(parent);
     98      // Note this may be an empty string, that'd the case for the root node
     99      // of a search, or a virtual root node, like the Library left pane.
    100      parentGuid = parent.bookmarkGuid;
    101    }
    102 
    103    let focusedElement = aInitInfo.focusedElement;
    104    let onPanelReady = aInitInfo.onPanelReady;
    105 
    106    return (this._paneInfo = {
    107      itemGuid,
    108      parentGuid,
    109      isItem,
    110      isURI,
    111      uri,
    112      title,
    113      isBookmark,
    114      isFolderShortcut,
    115      addedMultipleBookmarks,
    116      isParentReadOnly,
    117      bulkTagging,
    118      uris,
    119      visibleRows,
    120      postData,
    121      isTag,
    122      focusedElement,
    123      onPanelReady,
    124      tag,
    125    });
    126  },
    127 
    128  get initialized() {
    129    return this._paneInfo != null;
    130  },
    131 
    132  /**
    133   * The concrete bookmark GUID is either the bookmark one or, for folder
    134   * shortcuts, the target one.
    135   *
    136   * @returns {string} GUID of the loaded bookmark, or null if not a bookmark.
    137   */
    138  get concreteGuid() {
    139    if (
    140      !this.initialized ||
    141      this._paneInfo.isTag ||
    142      this._paneInfo.bulkTagging
    143    ) {
    144      return null;
    145    }
    146    return this._paneInfo.itemGuid;
    147  },
    148 
    149  get uri() {
    150    if (!this.initialized) {
    151      return null;
    152    }
    153    if (this._paneInfo.bulkTagging) {
    154      return this._paneInfo.uris[0];
    155    }
    156    return this._paneInfo.uri;
    157  },
    158 
    159  get multiEdit() {
    160    return this.initialized && this._paneInfo.bulkTagging;
    161  },
    162 
    163  // Check if the pane is initialized to show only read-only fields.
    164  get readOnly() {
    165    // TODO (Bug 1120314): Folder shortcuts are currently read-only due to some
    166    // quirky implementation details (the most important being the "smart"
    167    // semantics of node.title that makes hard to edit the right entry).
    168    // This pane is read-only if:
    169    //  * the panel is not initialized
    170    //  * the node is a folder shortcut
    171    //  * the node is not bookmarked and not a tag container
    172    //  * the node is child of a read-only container and is not a bookmarked
    173    //    URI nor a tag container
    174    return (
    175      !this.initialized ||
    176      this._paneInfo.isFolderShortcut ||
    177      (!this._paneInfo.isItem && !this._paneInfo.isTag) ||
    178      (this._paneInfo.isParentReadOnly &&
    179        !this._paneInfo.isBookmark &&
    180        !this._paneInfo.isTag)
    181    );
    182  },
    183 
    184  get didChangeFolder() {
    185    return this._didChangeFolder;
    186  },
    187 
    188  // the first field which was edited after this panel was initialized for
    189  // a certain item
    190  _firstEditedField: "",
    191 
    192  _initNamePicker() {
    193    if (this._paneInfo.bulkTagging && !this._paneInfo.addedMultipleBookmarks) {
    194      throw new Error("_initNamePicker called unexpectedly");
    195    }
    196 
    197    // title may by null, which, for us, is the same as an empty string.
    198    this._initTextField(
    199      this._namePicker,
    200      this._paneInfo.title || this._paneInfo.tag || ""
    201    );
    202  },
    203 
    204  _initLocationField() {
    205    if (!this._paneInfo.isURI) {
    206      throw new Error("_initLocationField called unexpectedly");
    207    }
    208    this._initTextField(this._locationField, this._paneInfo.uri.spec);
    209  },
    210 
    211  async _initKeywordField(newKeyword = "") {
    212    if (!this._paneInfo.isBookmark) {
    213      throw new Error("_initKeywordField called unexpectedly");
    214    }
    215 
    216    // Reset the field status synchronously now, eventually we'll reinit it
    217    // later if we find an existing keyword. This way we can ensure to be in a
    218    // consistent status when reusing the panel across different bookmarks.
    219    this._keyword = newKeyword;
    220    this._initTextField(this._keywordField, newKeyword);
    221 
    222    if (!newKeyword) {
    223      let entries = [];
    224      await PlacesUtils.keywords.fetch({ url: this._paneInfo.uri.spec }, e =>
    225        entries.push(e)
    226      );
    227      if (entries.length) {
    228        // We show an existing keyword if either POST data was not provided, or
    229        // if the POST data is the same.
    230        let existingKeyword = entries[0].keyword;
    231        let postData = this._paneInfo.postData;
    232        if (postData) {
    233          let sameEntry = entries.find(e => e.postData === postData);
    234          existingKeyword = sameEntry ? sameEntry.keyword : "";
    235        }
    236        if (existingKeyword) {
    237          this._keyword = existingKeyword;
    238          // Update the text field to the existing keyword.
    239          this._initTextField(this._keywordField, this._keyword);
    240        }
    241      }
    242    }
    243  },
    244 
    245  async _initAllTags() {
    246    this._allTags = new Map();
    247    const fetchedTags = await PlacesUtils.bookmarks.fetchTags();
    248    for (const tag of fetchedTags) {
    249      this._allTags?.set(tag.name.toLowerCase(), tag.name);
    250    }
    251  },
    252 
    253  /**
    254   * Initialize the panel.
    255   *
    256   * @param {object} aInfo
    257   *   The initialization info.
    258   * @param {object} [aInfo.node]
    259   *   If aInfo.uris is not specified, this must be specified.
    260   *   Either a result node or a node-like object representing the item to be edited.
    261   *   A node-like object must have the following properties (with values that
    262   *   match exactly those a result node would have):
    263   *   bookmarkGuid, uri, title, type, …
    264   * @param {nsIURI[]} [aInfo.uris]
    265   *   If aInfo.node is not specified, this must be specified.
    266   *   An array of uris for bulk tagging.
    267   * @param {string[]} [aInfo.hiddenRows]
    268   *   List of rows to be hidden regardless of the item edited. Possible values:
    269   *   "title", "location", "keyword", "folderPicker".
    270   */
    271  async initPanel(aInfo) {
    272    const deferred = (this._initPanelDeferred = Promise.withResolvers());
    273    try {
    274      if (typeof aInfo != "object" || aInfo === null) {
    275        throw new Error("aInfo must be an object.");
    276      }
    277      if ("node" in aInfo) {
    278        try {
    279          aInfo.node.type;
    280        } catch (e) {
    281          // If the lazy loader for |type| generates an exception, it means that
    282          // this bookmark could not be loaded. This sometimes happens when tests
    283          // create a bookmark by clicking the bookmark star, then try to cleanup
    284          // before the bookmark panel has finished opening. Either way, if we
    285          // cannot retrieve the bookmark information, we cannot open the panel.
    286          return;
    287        }
    288      }
    289 
    290      // For sanity ensure that the implementer has uninited the panel before
    291      // trying to init it again, or we could end up leaking due to observers.
    292      if (this.initialized) {
    293        this.uninitPanel(false);
    294      }
    295 
    296      this._didChangeFolder = false;
    297      this.transactionPromises = [];
    298 
    299      let {
    300        parentGuid,
    301        isItem,
    302        isURI,
    303        isBookmark,
    304        addedMultipleBookmarks,
    305        bulkTagging,
    306        uris,
    307        visibleRows,
    308        focusedElement,
    309        onPanelReady,
    310      } = this._setPaneInfo(aInfo);
    311 
    312      // initPanel can be called multiple times in a row,
    313      // and awaits Promises. If the reference to `instance`
    314      // changes, it must mean another caller has called
    315      // initPanel again, so bail out of the initialization.
    316      let instance = (this._instance = {});
    317 
    318      // If we're creating a new item on the toolbar, show it:
    319      if (
    320        aInfo.isNewBookmark &&
    321        parentGuid == PlacesUtils.bookmarks.toolbarGuid
    322      ) {
    323        this._autoshowBookmarksToolbar();
    324      }
    325 
    326      // Observe changes.
    327      if (!this._observersAdded) {
    328        this.handlePlacesEvents = this.handlePlacesEvents.bind(this);
    329        PlacesUtils.observers.addListener(
    330          ["bookmark-title-changed"],
    331          this.handlePlacesEvents
    332        );
    333        window.addEventListener("unload", this);
    334 
    335        let panel = document.getElementById("editBookmarkPanelContent");
    336        panel.addEventListener("change", this);
    337        panel.addEventListener("command", this);
    338 
    339        this._observersAdded = true;
    340      }
    341 
    342      let showOrCollapse = (
    343        rowId,
    344        isAppropriateForInput,
    345        nameInHiddenRows = null
    346      ) => {
    347        let visible = isAppropriateForInput;
    348        if (visible && "hiddenRows" in aInfo && nameInHiddenRows) {
    349          visible &= !aInfo.hiddenRows.includes(nameInHiddenRows);
    350        }
    351        if (visible) {
    352          visibleRows.add(rowId);
    353        }
    354        const cells = document.getElementsByClassName("editBMPanel_" + rowId);
    355        for (const cell of cells) {
    356          cell.hidden = !visible;
    357        }
    358        return visible;
    359      };
    360 
    361      if (
    362        showOrCollapse(
    363          "nameRow",
    364          !bulkTagging || addedMultipleBookmarks,
    365          "name"
    366        )
    367      ) {
    368        this._initNamePicker();
    369        this._namePicker.readOnly = this.readOnly;
    370      }
    371 
    372      // In some cases we want to hide the location field, since it's not
    373      // human-readable, but we still want to initialize it.
    374      showOrCollapse("locationRow", isURI, "location");
    375      if (isURI) {
    376        this._initLocationField();
    377        this._locationField.readOnly = this.readOnly;
    378      }
    379 
    380      if (showOrCollapse("keywordRow", isBookmark, "keyword")) {
    381        await this._initKeywordField().catch(console.error);
    382        // paneInfo can be null if paneInfo is uninitialized while
    383        // the process above is awaiting initialization
    384        if (instance != this._instance || this._paneInfo == null) {
    385          return;
    386        }
    387        this._keywordField.readOnly = this.readOnly;
    388      }
    389 
    390      // Collapse the tag selector if the item does not accept tags.
    391      if (showOrCollapse("tagsRow", isBookmark || bulkTagging, "tags")) {
    392        this._initTagsField();
    393      } else if (!this._element("tagsSelectorRow").hidden) {
    394        this.toggleTagsSelector().catch(console.error);
    395      }
    396 
    397      // Folder picker.
    398      // Technically we should check that the item is not moveable, but that's
    399      // not cheap (we don't always have the parent), and there's no use case for
    400      // this (it's only the Star UI that shows the folderPicker)
    401      if (showOrCollapse("folderRow", isItem, "folderPicker")) {
    402        await this._initFolderMenuList(parentGuid).catch(console.error);
    403        if (instance != this._instance || this._paneInfo == null) {
    404          return;
    405        }
    406      }
    407 
    408      // Selection count.
    409      if (showOrCollapse("selectionCount", bulkTagging)) {
    410        document.l10n.setAttributes(
    411          this._element("itemsCountText"),
    412          "places-details-pane-items-count",
    413          { count: uris.length }
    414        );
    415      }
    416 
    417      let focusElement = () => {
    418        // The focusedElement possible values are:
    419        //  * preferred: focus the field that the user touched first the last
    420        //    time the pane was shown (either namePicker or tagsField)
    421        //  * first: focus the first non hidden input
    422        // Note: since all controls are hidden by default, we don't get the
    423        // default XUL dialog behavior, that selects the first control, so we set
    424        // the focus explicitly.
    425 
    426        let elt;
    427        if (focusedElement === "preferred") {
    428          elt = this._element(
    429            Services.prefs.getCharPref(
    430              "browser.bookmarks.editDialog.firstEditField"
    431            )
    432          );
    433          if (elt.parentNode.hidden) {
    434            focusedElement = "first";
    435          }
    436        }
    437        if (focusedElement === "first") {
    438          elt = document
    439            .getElementById("editBookmarkPanelContent")
    440            .querySelector('input:not([hidden="true"])');
    441        }
    442 
    443        if (elt) {
    444          elt.focus({ preventScroll: true });
    445          elt.select();
    446        }
    447      };
    448 
    449      if (onPanelReady) {
    450        onPanelReady(focusElement);
    451      } else {
    452        focusElement();
    453      }
    454 
    455      if (this._updateTagsDeferred) {
    456        await this._updateTagsDeferred.promise;
    457      }
    458 
    459      this._bookmarkState = this.makeNewStateObject({
    460        children: aInfo.node?.children,
    461        index: aInfo.node?.index,
    462        isFolder:
    463          aInfo.node != null && PlacesUtils.nodeIsFolderOrShortcut(aInfo.node),
    464      });
    465      if (isBookmark || bulkTagging) {
    466        await this._initAllTags();
    467        await this._rebuildTagsSelectorList();
    468      }
    469    } finally {
    470      deferred.resolve();
    471      if (this._initPanelDeferred === deferred) {
    472        // Since change listeners check _initPanelDeferred for truthiness, we
    473        // can prevent unnecessary awaits by setting it back to null.
    474        this._initPanelDeferred = null;
    475      }
    476    }
    477  },
    478 
    479  /**
    480   * Finds tags that are in common among this._currentInfo.uris;
    481   *
    482   * @returns {string[]}
    483   */
    484  _getCommonTags() {
    485    if ("_cachedCommonTags" in this._paneInfo) {
    486      return this._paneInfo._cachedCommonTags;
    487    }
    488 
    489    let uris = [...this._paneInfo.uris];
    490    let firstURI = uris.shift();
    491    let commonTags = new Set(PlacesUtils.tagging.getTagsForURI(firstURI));
    492    if (commonTags.size == 0) {
    493      return (this._cachedCommonTags = []);
    494    }
    495 
    496    for (let uri of uris) {
    497      let curentURITags = PlacesUtils.tagging.getTagsForURI(uri);
    498      for (let tag of commonTags) {
    499        if (!curentURITags.includes(tag)) {
    500          commonTags.delete(tag);
    501          if (commonTags.size == 0) {
    502            return (this._paneInfo.cachedCommonTags = []);
    503          }
    504        }
    505      }
    506    }
    507    return (this._paneInfo._cachedCommonTags = [...commonTags]);
    508  },
    509 
    510  _initTextField(aElement, aValue) {
    511    if (aElement.value != aValue) {
    512      aElement.value = aValue;
    513 
    514      // Clear the editor's undo stack
    515      // FYI: editor may be null.
    516      aElement.editor?.clearUndoRedo();
    517    }
    518  },
    519 
    520  /**
    521   * Appends a menu-item representing a bookmarks folder to a menu-popup.
    522   *
    523   * @param {DOMElement} aMenupopup
    524   *   The popup to which the menu-item should be added.
    525   * @param {string} aFolderGuid
    526   *   The identifier of the bookmarks folder.
    527   * @param {string} aTitle
    528   *   The title to use as a label.
    529   * @returns {DOMElement}
    530   *   The new menu item.
    531   */
    532  _appendFolderItemToMenupopup(aMenupopup, aFolderGuid, aTitle) {
    533    // First make sure the folders-separator is visible
    534    this._element("foldersSeparator").hidden = false;
    535 
    536    var folderMenuItem = document.createXULElement("menuitem");
    537    folderMenuItem.folderGuid = aFolderGuid;
    538    folderMenuItem.setAttribute("label", aTitle);
    539    folderMenuItem.className = "menuitem-iconic folder-icon";
    540    aMenupopup.appendChild(folderMenuItem);
    541    return folderMenuItem;
    542  },
    543 
    544  async _initFolderMenuList(aSelectedFolderGuid) {
    545    // clean up first
    546    var menupopup = this._folderMenuList.menupopup;
    547    while (menupopup.children.length > 6) {
    548      menupopup.removeChild(menupopup.lastElementChild);
    549    }
    550 
    551    // Build the static list
    552    if (!this._staticFoldersListBuilt) {
    553      let unfiledItem = this._element("unfiledRootItem");
    554      unfiledItem.label = PlacesUtils.getString("OtherBookmarksFolderTitle");
    555      unfiledItem.folderGuid = PlacesUtils.bookmarks.unfiledGuid;
    556      let bmMenuItem = this._element("bmRootItem");
    557      bmMenuItem.label = PlacesUtils.getString("BookmarksMenuFolderTitle");
    558      bmMenuItem.folderGuid = PlacesUtils.bookmarks.menuGuid;
    559      let toolbarItem = this._element("toolbarFolderItem");
    560      toolbarItem.label = PlacesUtils.getString("BookmarksToolbarFolderTitle");
    561      toolbarItem.folderGuid = PlacesUtils.bookmarks.toolbarGuid;
    562      this._staticFoldersListBuilt = true;
    563    }
    564 
    565    // List of recently used folders:
    566    let lastUsedFolderGuids = await PlacesUtils.metadata.get(
    567      PlacesUIUtils.LAST_USED_FOLDERS_META_KEY,
    568      []
    569    );
    570 
    571    /**
    572     * The list of last used folders is sorted in most-recent first order.
    573     *
    574     * First we build the annotated folders array, each item has both the
    575     * folder identifier and the time at which it was last-used by this dialog
    576     * set. Then we sort it descendingly based on the time field.
    577     */
    578    this._recentFolders = [];
    579    for (let guid of lastUsedFolderGuids) {
    580      let bm = await PlacesUtils.bookmarks.fetch(guid);
    581      if (bm) {
    582        let title = PlacesUtils.bookmarks.getLocalizedTitle(bm);
    583        this._recentFolders.push({ guid, title });
    584      }
    585    }
    586 
    587    var numberOfItems = Math.min(
    588      PlacesUIUtils.maxRecentFolders,
    589      this._recentFolders.length
    590    );
    591    for (let i = 0; i < numberOfItems; i++) {
    592      await this._appendFolderItemToMenupopup(
    593        menupopup,
    594        this._recentFolders[i].guid,
    595        this._recentFolders[i].title
    596      );
    597    }
    598 
    599    let title = (await PlacesUtils.bookmarks.fetch(aSelectedFolderGuid)).title;
    600    var defaultItem = this._getFolderMenuItem(aSelectedFolderGuid, title);
    601    this._folderMenuList.selectedItem = defaultItem;
    602    // Ensure the selectedGuid attribute is set correctly (the above line wouldn't
    603    // necessary trigger a select event, so handle it manually, then add the
    604    // listener).
    605    this._onFolderListSelected();
    606 
    607    this._folderMenuList.addEventListener("select", this);
    608    this._folderMenuList.addEventListener("command", this);
    609    this._folderMenuListListenerAdded = true;
    610 
    611    // Hide the folders-separator if no folder is annotated as recently-used
    612    this._element("foldersSeparator").hidden = menupopup.children.length <= 6;
    613    this._folderMenuList.disabled = this.readOnly;
    614  },
    615 
    616  _onFolderListSelected() {
    617    // Set a selectedGuid attribute to show special icons
    618    let folderGuid = this.selectedFolderGuid;
    619    if (folderGuid) {
    620      this._folderMenuList.setAttribute("selectedGuid", folderGuid);
    621    } else {
    622      this._folderMenuList.removeAttribute("selectedGuid");
    623    }
    624  },
    625 
    626  _element(aID) {
    627    return document.getElementById("editBMPanel_" + aID);
    628  },
    629 
    630  uninitPanel(aHideCollapsibleElements) {
    631    if (aHideCollapsibleElements) {
    632      // Hide the folder tree if it was previously visible.
    633      var folderTreeRow = this._element("folderTreeRow");
    634      if (!folderTreeRow.hidden) {
    635        this.toggleFolderTreeVisibility();
    636      }
    637 
    638      // Hide the tag selector if it was previously visible.
    639      var tagsSelectorRow = this._element("tagsSelectorRow");
    640      if (!tagsSelectorRow.hidden) {
    641        this.toggleTagsSelector().catch(console.error);
    642      }
    643    }
    644 
    645    if (this._observersAdded) {
    646      PlacesUtils.observers.removeListener(
    647        ["bookmark-title-changed"],
    648        this.handlePlacesEvents
    649      );
    650      window.removeEventListener("unload", this);
    651      let panel = document.getElementById("editBookmarkPanelContent");
    652      panel.removeEventListener("change", this);
    653      panel.removeEventListener("command", this);
    654      this._observersAdded = false;
    655    }
    656 
    657    if (this._folderMenuListListenerAdded) {
    658      this._folderMenuList.removeEventListener("select", this);
    659      this._folderMenuList.removeEventListener("command", this);
    660      this._folderMenuListListenerAdded = false;
    661    }
    662 
    663    this._setPaneInfo(null);
    664    this._firstEditedField = "";
    665    this._didChangeFolder = false;
    666    this.transactionPromises = [];
    667    this._bookmarkState = null;
    668    this._allTags = null;
    669  },
    670 
    671  get selectedFolderGuid() {
    672    return (
    673      this._folderMenuList.selectedItem &&
    674      this._folderMenuList.selectedItem.folderGuid
    675    );
    676  },
    677 
    678  makeNewStateObject(extraOptions) {
    679    if (
    680      this._paneInfo.isItem ||
    681      this._paneInfo.isTag ||
    682      this._paneInfo.bulkTagging
    683    ) {
    684      const isLibraryWindow =
    685        document.documentElement.getAttribute("windowtype") ===
    686        "Places:Organizer";
    687      const options = {
    688        autosave: isLibraryWindow,
    689        info: this._paneInfo,
    690        ...extraOptions,
    691      };
    692 
    693      if (this._paneInfo.isBookmark) {
    694        options.tags = this._element("tagsField").value;
    695        options.keyword = this._keyword;
    696      }
    697 
    698      if (this._paneInfo.bulkTagging) {
    699        options.tags = this._element("tagsField").value;
    700      }
    701 
    702      return new PlacesUIUtils.BookmarkState(options);
    703    }
    704    return null;
    705  },
    706 
    707  async onTagsFieldChange() {
    708    // Check for _paneInfo existing as the dialog may be closing but receiving
    709    // async updates from unresolved promises.
    710    if (
    711      this._paneInfo &&
    712      (this._paneInfo.isURI || this._paneInfo.bulkTagging)
    713    ) {
    714      if (this._initPanelDeferred) {
    715        await this._initPanelDeferred.promise;
    716      }
    717      this._updateTags().then(() => {
    718        // Check _paneInfo here as we might be closing the dialog.
    719        if (this._paneInfo) {
    720          this._mayUpdateFirstEditField("tagsField");
    721        }
    722      }, console.error);
    723    }
    724  },
    725 
    726  /**
    727   * Handle tag list updates from the input field or selector box.
    728   */
    729  async _updateTags() {
    730    const deferred = (this._updateTagsDeferred = Promise.withResolvers());
    731    try {
    732      const inputTags = this._getTagsArrayFromTagsInputField();
    733      const isLibraryWindow =
    734        document.documentElement.getAttribute("windowtype") ===
    735        "Places:Organizer";
    736      await this._bookmarkState._tagsChanged(inputTags);
    737 
    738      if (isLibraryWindow) {
    739        // Ensure the tagsField is in sync, clean it up from empty tags
    740        delete this._paneInfo._cachedCommonTags;
    741        const currentTags = this._paneInfo.bulkTagging
    742          ? this._getCommonTags()
    743          : PlacesUtils.tagging.getTagsForURI(this._paneInfo.uri);
    744        this._initTextField(this._tagsField, currentTags.join(", "), false);
    745        await this._initAllTags();
    746      } else {
    747        // Autosave is disabled. Update _allTags in memory so that the selector
    748        // list shows any new tags that haven't been saved yet.
    749        inputTags.forEach(tag => this._allTags?.set(tag.toLowerCase(), tag));
    750      }
    751      await this._rebuildTagsSelectorList();
    752    } finally {
    753      deferred.resolve();
    754      if (this._updateTagsDeferred === deferred) {
    755        // Since initPanel() checks _updateTagsDeferred for truthiness, we can
    756        // prevent unnecessary awaits by setting it back to null.
    757        this._updateTagsDeferred = null;
    758      }
    759    }
    760  },
    761 
    762  /**
    763   * Stores the first-edit field for this dialog, if the passed-in field
    764   * is indeed the first edited field.
    765   *
    766   * @param {string} aNewField
    767   *   The id of the field that may be set (without the "editBMPanel_" prefix).
    768   */
    769  _mayUpdateFirstEditField(aNewField) {
    770    // * The first-edit-field behavior is not applied in the multi-edit case
    771    // * if this._firstEditedField is already set, this is not the first field,
    772    //   so there's nothing to do
    773    if (this._paneInfo.bulkTagging || this._firstEditedField) {
    774      return;
    775    }
    776 
    777    this._firstEditedField = aNewField;
    778 
    779    // set the pref
    780    Services.prefs.setCharPref(
    781      "browser.bookmarks.editDialog.firstEditField",
    782      aNewField
    783    );
    784  },
    785 
    786  async onNamePickerChange() {
    787    if (this.readOnly || !(this._paneInfo.isItem || this._paneInfo.isTag)) {
    788      return;
    789    }
    790    if (this._initPanelDeferred) {
    791      await this._initPanelDeferred.promise;
    792    }
    793 
    794    // Here we update either the item title or its cached static title
    795    if (this._paneInfo.isTag) {
    796      let tag = this._namePicker.value;
    797      if (!tag || tag.includes("&")) {
    798        // We don't allow setting an empty title for a tag, restore the old one.
    799        this._initNamePicker();
    800        return;
    801      }
    802 
    803      this._bookmarkState._titleChanged(tag);
    804      return;
    805    }
    806    this._mayUpdateFirstEditField("namePicker");
    807    this._bookmarkState._titleChanged(this._namePicker.value);
    808  },
    809 
    810  async onLocationFieldChange() {
    811    if (this.readOnly || !this._paneInfo.isBookmark) {
    812      return;
    813    }
    814    if (this._initPanelDeferred) {
    815      await this._initPanelDeferred.promise;
    816    }
    817 
    818    let newURI;
    819    try {
    820      newURI = Services.uriFixup.getFixupURIInfo(
    821        this._locationField.value
    822      ).preferredURI;
    823    } catch (ex) {
    824      // TODO: Bug 1089141 - Provide some feedback about the invalid url.
    825      return;
    826    }
    827 
    828    if (this._paneInfo.uri.equals(newURI)) {
    829      return;
    830    }
    831    this._bookmarkState._locationChanged(newURI.spec);
    832  },
    833 
    834  async onKeywordFieldChange() {
    835    if (this.readOnly || !this._paneInfo.isBookmark) {
    836      return;
    837    }
    838    if (this._initPanelDeferred) {
    839      await this._initPanelDeferred.promise;
    840    }
    841    this._bookmarkState._keywordChanged(this._keywordField.value);
    842  },
    843 
    844  toggleFolderTreeVisibility() {
    845    let expander = this._element("foldersExpander");
    846    let folderTreeRow = this._element("folderTreeRow");
    847    let wasHidden = folderTreeRow.hidden;
    848    expander.classList.toggle("expander-up", wasHidden);
    849    expander.classList.toggle("expander-down", !wasHidden);
    850    if (!wasHidden) {
    851      document.l10n.setAttributes(
    852        expander,
    853        "bookmark-overlay-folders-expander2"
    854      );
    855      folderTreeRow.hidden = true;
    856      this._element("chooseFolderSeparator").hidden = this._element(
    857        "chooseFolderMenuItem"
    858      ).hidden = false;
    859      // Stop editing if we were (will no-op if not). This avoids permanently
    860      // breaking the tree if/when it is reshown.
    861      this._folderTree.stopEditing(false);
    862      // Unlinking the view will break the connection with the result. We don't
    863      // want to pay for live updates while the view is not visible.
    864      this._folderTree.view = null;
    865    } else {
    866      document.l10n.setAttributes(
    867        expander,
    868        "bookmark-overlay-folders-expander-hide"
    869      );
    870      folderTreeRow.hidden = false;
    871 
    872      // XXXmano: Ideally we would only do this once, but for some odd reason,
    873      // the editable mode set on this tree, together with its hidden state
    874      // breaks the view.
    875      const FOLDER_TREE_PLACE_URI =
    876        "place:excludeItems=1&excludeQueries=1&type=" +
    877        Ci.nsINavHistoryQueryOptions.RESULTS_AS_ROOTS_QUERY;
    878      this._folderTree.place = FOLDER_TREE_PLACE_URI;
    879 
    880      this._element("chooseFolderSeparator").hidden = this._element(
    881        "chooseFolderMenuItem"
    882      ).hidden = true;
    883      this._folderTree.selectItems([this._bookmarkState.parentGuid]);
    884      this._folderTree.focus();
    885    }
    886  },
    887 
    888  /**
    889   * Get the corresponding menu-item in the folder-menu-list for a bookmarks
    890   * folder if such an item exists. Otherwise, this creates a menu-item for the
    891   * folder. If the items-count limit (see
    892   * browser.bookmarks.editDialog.maxRecentFolders preference) is reached, the
    893   * new item replaces the last menu-item.
    894   *
    895   * @param {string} aFolderGuid
    896   *   The identifier of the bookmarks folder.
    897   * @param {string} aTitle
    898   *   The title to use in case of menuitem creation.
    899   * @returns {DOMElement}
    900   *   The handle to the menuitem.
    901   */
    902  _getFolderMenuItem(aFolderGuid, aTitle) {
    903    let menupopup = this._folderMenuList.menupopup;
    904    let menuItem = Array.prototype.find.call(
    905      menupopup.children,
    906      item => item.folderGuid === aFolderGuid
    907    );
    908    if (menuItem !== undefined) {
    909      return menuItem;
    910    }
    911 
    912    // 3 special folders + separator + folder-items-count limit
    913    if (menupopup.children.length == 4 + PlacesUIUtils.maxRecentFolders) {
    914      menupopup.removeChild(menupopup.lastElementChild);
    915    }
    916 
    917    return this._appendFolderItemToMenupopup(menupopup, aFolderGuid, aTitle);
    918  },
    919 
    920  async onFolderMenuListCommand(aEvent) {
    921    // Check for _paneInfo existing as the dialog may be closing but receiving
    922    // async updates from unresolved promises.
    923    if (!this._paneInfo) {
    924      return;
    925    }
    926 
    927    if (aEvent.target.id == "editBMPanel_chooseFolderMenuItem") {
    928      // reset the selection back to where it was and expand the tree
    929      // (this menu-item is hidden when the tree is already visible
    930      let item = this._getFolderMenuItem(
    931        this._bookmarkState._originalState.parentGuid,
    932        this._bookmarkState._originalState.title
    933      );
    934      this._folderMenuList.selectedItem = item;
    935      // XXXmano HACK: setTimeout 100, otherwise focus goes back to the
    936      // menulist right away
    937      setTimeout(() => this.toggleFolderTreeVisibility(), 100);
    938      return;
    939    }
    940 
    941    // Move the item
    942    let containerGuid = this._folderMenuList.selectedItem.folderGuid;
    943    if (this._bookmarkState.parentGuid != containerGuid) {
    944      this._bookmarkState._parentGuidChanged(containerGuid);
    945 
    946      // Auto-show the bookmarks toolbar when adding / moving an item there.
    947      if (containerGuid == PlacesUtils.bookmarks.toolbarGuid) {
    948        this._autoshowBookmarksToolbar();
    949      }
    950 
    951      // Unless the user cancels the panel, we'll use the chosen folder as
    952      // the default for new bookmarks.
    953      this._didChangeFolder = true;
    954    }
    955 
    956    // Update folder-tree selection
    957    var folderTreeRow = this._element("folderTreeRow");
    958    if (!folderTreeRow.hidden) {
    959      var selectedNode = this._folderTree.selectedNode;
    960      if (
    961        !selectedNode ||
    962        PlacesUtils.getConcreteItemGuid(selectedNode) != containerGuid
    963      ) {
    964        this._folderTree.selectItems([containerGuid]);
    965      }
    966    }
    967  },
    968 
    969  _autoshowBookmarksToolbar() {
    970    let neverShowToolbar =
    971      Services.prefs.getCharPref(
    972        "browser.toolbars.bookmarks.visibility",
    973        "newtab"
    974      ) == "never";
    975    let toolbar = document.getElementById("PersonalToolbar");
    976    if (!toolbar.collapsed || neverShowToolbar) {
    977      return;
    978    }
    979 
    980    let placement = CustomizableUI.getPlacementOfWidget("personal-bookmarks");
    981    let area = placement && placement.area;
    982    if (area != CustomizableUI.AREA_BOOKMARKS) {
    983      return;
    984    }
    985 
    986    // Show the toolbar but don't persist it permanently open
    987    setToolbarVisibility(toolbar, true, false);
    988  },
    989 
    990  onFolderTreeSelect() {
    991    // Ignore this event when the folder tree is hidden, even if the tree is
    992    // alive, it's clearly not a user activated action.
    993    if (this._element("folderTreeRow").hidden) {
    994      return;
    995    }
    996 
    997    var selectedNode = this._folderTree.selectedNode;
    998 
    999    // Disable the "New Folder" button if we cannot create a new folder
   1000    this._element("newFolderButton").disabled =
   1001      !this._folderTree.insertionPoint || !selectedNode;
   1002 
   1003    if (!selectedNode) {
   1004      return;
   1005    }
   1006 
   1007    var folderGuid = PlacesUtils.getConcreteItemGuid(selectedNode);
   1008    if (this._folderMenuList.selectedItem.folderGuid == folderGuid) {
   1009      return;
   1010    }
   1011 
   1012    var folderItem = this._getFolderMenuItem(folderGuid, selectedNode.title);
   1013    this._folderMenuList.selectedItem = folderItem;
   1014    folderItem.doCommand();
   1015  },
   1016 
   1017  async _rebuildTagsSelectorList() {
   1018    let tagsSelector = this._element("tagsSelector");
   1019    let tagsSelectorRow = this._element("tagsSelectorRow");
   1020    if (tagsSelectorRow.hidden) {
   1021      return;
   1022    }
   1023 
   1024    let selectedIndex = tagsSelector.selectedIndex;
   1025    let selectedTag =
   1026      selectedIndex >= 0 ? tagsSelector.selectedItem.label : null;
   1027 
   1028    while (tagsSelector.hasChildNodes()) {
   1029      tagsSelector.removeChild(tagsSelector.lastElementChild);
   1030    }
   1031 
   1032    let tagsInField = this._getTagsArrayFromTagsInputField();
   1033 
   1034    let fragment = document.createDocumentFragment();
   1035    let sortedTags = this._allTags ? [...this._allTags.values()].sort() : [];
   1036 
   1037    for (let i = 0; i < sortedTags.length; i++) {
   1038      let tag = sortedTags[i];
   1039      let elt = document.createXULElement("richlistitem");
   1040      elt.appendChild(document.createXULElement("image"));
   1041      let label = document.createXULElement("label");
   1042      label.setAttribute("value", tag);
   1043      elt.appendChild(label);
   1044      if (tagsInField.includes(tag)) {
   1045        elt.setAttribute("checked", "true");
   1046      }
   1047      fragment.appendChild(elt);
   1048      if (selectedTag === tag) {
   1049        selectedIndex = i;
   1050      }
   1051    }
   1052    tagsSelector.appendChild(fragment);
   1053 
   1054    if (selectedIndex >= 0 && tagsSelector.itemCount > 0) {
   1055      selectedIndex = Math.min(selectedIndex, tagsSelector.itemCount - 1);
   1056      tagsSelector.selectedIndex = selectedIndex;
   1057      tagsSelector.ensureIndexIsVisible(selectedIndex);
   1058    }
   1059    let event = new CustomEvent("BookmarkTagsSelectorUpdated", {
   1060      bubbles: true,
   1061    });
   1062    tagsSelector.dispatchEvent(event);
   1063  },
   1064 
   1065  async toggleTagsSelector() {
   1066    var tagsSelector = this._element("tagsSelector");
   1067    var tagsSelectorRow = this._element("tagsSelectorRow");
   1068    var expander = this._element("tagsSelectorExpander");
   1069    expander.classList.toggle("expander-up", tagsSelectorRow.hidden);
   1070    expander.classList.toggle("expander-down", !tagsSelectorRow.hidden);
   1071    if (tagsSelectorRow.hidden) {
   1072      document.l10n.setAttributes(
   1073        expander,
   1074        "bookmark-overlay-tags-expander-hide"
   1075      );
   1076      tagsSelectorRow.hidden = false;
   1077      await this._rebuildTagsSelectorList();
   1078 
   1079      // This is a no-op if we've added the listener.
   1080      tagsSelector.addEventListener("mousedown", this);
   1081      tagsSelector.addEventListener("keypress", this);
   1082    } else {
   1083      document.l10n.setAttributes(expander, "bookmark-overlay-tags-expander2");
   1084      tagsSelectorRow.hidden = true;
   1085 
   1086      // This is a no-op if we've removed the listener.
   1087      tagsSelector.removeEventListener("mousedown", this);
   1088      tagsSelector.removeEventListener("keypress", this);
   1089    }
   1090  },
   1091 
   1092  /**
   1093   * Splits "tagsField" element value, returning an array of valid tag strings.
   1094   *
   1095   * @returns {string[]}
   1096   *   Array of tag strings found in the field value.
   1097   */
   1098  _getTagsArrayFromTagsInputField() {
   1099    let tags = this._element("tagsField").value;
   1100    return tags
   1101      .trim()
   1102      .split(/\s*,\s*/) // Split on commas and remove spaces.
   1103      .filter(tag => !!tag.length); // Kill empty tags.
   1104  },
   1105 
   1106  async newFolder() {
   1107    let ip = this._folderTree.insertionPoint;
   1108 
   1109    // default to the bookmarks menu folder
   1110    if (!ip) {
   1111      ip = new PlacesInsertionPoint({
   1112        parentGuid: PlacesUtils.bookmarks.menuGuid,
   1113      });
   1114    }
   1115 
   1116    // XXXmano: add a separate "New Folder" string at some point...
   1117    let title = this._element("newFolderButton").label;
   1118    let promise = PlacesTransactions.NewFolder({
   1119      parentGuid: ip.guid,
   1120      title,
   1121      index: await ip.getIndex(),
   1122    }).transact();
   1123    this.transactionPromises.push(promise.catch(console.error));
   1124    let guid = await promise;
   1125 
   1126    this._folderTree.focus();
   1127    this._folderTree.selectItems([ip.guid]);
   1128    PlacesUtils.asContainer(this._folderTree.selectedNode).containerOpen = true;
   1129    this._folderTree.selectItems([guid]);
   1130    this._folderTree.startEditing(
   1131      this._folderTree.view.selection.currentIndex,
   1132      this._folderTree.columns.getFirstColumn()
   1133    );
   1134  },
   1135 
   1136  // EventListener
   1137  handleEvent(event) {
   1138    switch (event.type) {
   1139      case "mousedown":
   1140        if (event.button == 0) {
   1141          // Make sure the event is triggered on an item and not the empty space.
   1142          let item = event.target.closest("richlistbox,richlistitem");
   1143          if (item.localName == "richlistitem") {
   1144            this.toggleItemCheckbox(item);
   1145          }
   1146        }
   1147        break;
   1148      case "keypress":
   1149        if (event.key == " ") {
   1150          let item = event.target.currentItem;
   1151          if (item) {
   1152            this.toggleItemCheckbox(item);
   1153          }
   1154        }
   1155        break;
   1156      case "unload":
   1157        this.uninitPanel(false);
   1158        break;
   1159      case "select":
   1160        this._onFolderListSelected();
   1161        break;
   1162      case "change":
   1163        switch (event.target.id) {
   1164          case "editBMPanel_namePicker":
   1165            this.onNamePickerChange().catch(console.error);
   1166            break;
   1167 
   1168          case "editBMPanel_locationField":
   1169            this.onLocationFieldChange();
   1170            break;
   1171 
   1172          case "editBMPanel_tagsField":
   1173            this.onTagsFieldChange();
   1174            break;
   1175 
   1176          case "editBMPanel_keywordField":
   1177            this.onKeywordFieldChange();
   1178            break;
   1179        }
   1180        break;
   1181      case "command":
   1182        if (event.currentTarget.id === "editBMPanel_folderMenuList") {
   1183          this.onFolderMenuListCommand(event).catch(console.error);
   1184          return;
   1185        }
   1186 
   1187        switch (event.target.id) {
   1188          case "editBMPanel_foldersExpander":
   1189            this.toggleFolderTreeVisibility();
   1190            break;
   1191          case "editBMPanel_newFolderButton":
   1192            this.newFolder().catch(console.error);
   1193            break;
   1194          case "editBMPanel_tagsSelectorExpander":
   1195            this.toggleTagsSelector().catch(console.error);
   1196            break;
   1197        }
   1198        break;
   1199    }
   1200  },
   1201 
   1202  async handlePlacesEvents(events) {
   1203    for (const event of events) {
   1204      switch (event.type) {
   1205        case "bookmark-title-changed":
   1206          if (this._paneInfo.isItem || this._paneInfo.isTag) {
   1207            // This also updates titles of folders in the folder menu list.
   1208            this._onItemTitleChange(event.id, event.title, event.guid);
   1209          }
   1210          break;
   1211      }
   1212    }
   1213  },
   1214 
   1215  toggleItemCheckbox(item) {
   1216    // Update the tags field when items are checked/unchecked in the listbox
   1217    let tags = this._getTagsArrayFromTagsInputField();
   1218 
   1219    let curTagIndex = tags.indexOf(item.label);
   1220    let tagsSelector = this._element("tagsSelector");
   1221    tagsSelector.selectedItem = item;
   1222 
   1223    if (!item.hasAttribute("checked")) {
   1224      item.setAttribute("checked", "true");
   1225      if (curTagIndex == -1) {
   1226        tags.push(item.label);
   1227      }
   1228    } else {
   1229      item.removeAttribute("checked");
   1230      if (curTagIndex != -1) {
   1231        tags.splice(curTagIndex, 1);
   1232      }
   1233    }
   1234    this._element("tagsField").value = tags.join(", ");
   1235    this._updateTags();
   1236  },
   1237 
   1238  _initTagsField() {
   1239    let tags;
   1240    if (this._paneInfo.isURI) {
   1241      tags = PlacesUtils.tagging.getTagsForURI(this._paneInfo.uri);
   1242    } else if (this._paneInfo.bulkTagging) {
   1243      tags = this._getCommonTags();
   1244    } else {
   1245      throw new Error("_promiseTagsStr called unexpectedly");
   1246    }
   1247 
   1248    this._initTextField(this._tagsField, tags.join(", "));
   1249  },
   1250 
   1251  _onItemTitleChange(aItemId, aNewTitle, aGuid) {
   1252    if (this._paneInfo.visibleRows.has("folderRow")) {
   1253      // If the title of a folder which is listed within the folders
   1254      // menulist has been changed, we need to update the label of its
   1255      // representing element.
   1256      let menupopup = this._folderMenuList.menupopup;
   1257      for (let menuitem of menupopup.children) {
   1258        if ("folderGuid" in menuitem && menuitem.folderGuid == aGuid) {
   1259          menuitem.label = aNewTitle;
   1260          break;
   1261        }
   1262      }
   1263    }
   1264    // We need to also update title of recent folders.
   1265    if (this._recentFolders) {
   1266      for (let folder of this._recentFolders) {
   1267        if (folder.folderGuid == aGuid) {
   1268          folder.title = aNewTitle;
   1269          break;
   1270        }
   1271      }
   1272    }
   1273  },
   1274 
   1275  /**
   1276   * State object for the bookmark(s) currently being edited.
   1277   *
   1278   * @returns {BookmarkState} The bookmark state.
   1279   */
   1280  get bookmarkState() {
   1281    return this._bookmarkState;
   1282  },
   1283 };
   1284 
   1285 ChromeUtils.defineLazyGetter(gEditItemOverlay, "_folderTree", () => {
   1286  if (!customElements.get("places-tree")) {
   1287    Services.scriptloader.loadSubScript(
   1288      "chrome://browser/content/places/places-tree.js",
   1289      window
   1290    );
   1291  }
   1292  gEditItemOverlay._element("folderTreeRow").prepend(
   1293    MozXULElement.parseXULToFragment(`
   1294    <tree id="editBMPanel_folderTree"
   1295          class="placesTree"
   1296          is="places-tree"
   1297          data-l10n-id="bookmark-overlay-folders-tree"
   1298          editable="true"
   1299          disableUserActions="true"
   1300          hidecolumnpicker="true">
   1301      <treecols>
   1302        <treecol anonid="title" flex="1" primary="true" hideheader="true"/>
   1303      </treecols>
   1304      <treechildren flex="1"/>
   1305    </tree>
   1306  `)
   1307  );
   1308  const folderTree = gEditItemOverlay._element("folderTree");
   1309  folderTree.addEventListener("select", () =>
   1310    gEditItemOverlay.onFolderTreeSelect()
   1311  );
   1312  return folderTree;
   1313 });
   1314 
   1315 for (let elt of [
   1316  "folderMenuList",
   1317  "namePicker",
   1318  "locationField",
   1319  "keywordField",
   1320  "tagsField",
   1321 ]) {
   1322  let eltScoped = elt;
   1323  ChromeUtils.defineLazyGetter(gEditItemOverlay, `_${eltScoped}`, () =>
   1324    gEditItemOverlay._element(eltScoped)
   1325  );
   1326 }