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 }