bookmarkProperties.js (17318B)
1 /* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ 2 /* This Source Code Form is subject to the terms of the Mozilla Public 3 * License, v. 2.0. If a copy of the MPL was not distributed with this 4 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 5 6 /** 7 * The panel is initialized based on data given in the js object passed 8 * as window.arguments[0]. The object must have the following fields set: 9 * @ action (String). Possible values: 10 * - "add" - for adding a new item. 11 * @ type (String). Possible values: 12 * - "bookmark" 13 * - "folder" 14 * @ URIList (Array of nsIURI objects) - optional, list of uris to 15 * be bookmarked under the new folder. 16 * @ uri (nsIURI object) - optional, the default uri for the new item. 17 * The property is not used for the "folder with items" type. 18 * @ title (String) - optional, the default title for the new item. 19 * @ defaultInsertionPoint (InsertionPoint JS object) - optional, the 20 * default insertion point for the new item. 21 * @ keyword (String) - optional, the default keyword for the new item. 22 * @ postData (String) - optional, POST data to accompany the keyword. 23 * @ charSet (String) - optional, character-set to accompany the keyword. 24 * Notes: 25 * 1) If |uri| is set for a bookmark and |title| isn't, 26 * the dialog will query the history tables for the title associated 27 * with the given uri. If the dialog is set to adding a folder with 28 * bookmark items under it (see URIList), a default static title is 29 * used ("[Folder Name]"). 30 * 2) The index field of the default insertion point is ignored if 31 * the folder picker is shown. 32 * - "edit" - for editing a bookmark item or a folder. 33 * @ type (String). Possible values: 34 * - "bookmark" 35 * @ node (an nsINavHistoryResultNode object) - a node representing 36 * the bookmark. 37 * - "folder" 38 * @ node (an nsINavHistoryResultNode object) - a node representing 39 * the folder. 40 * @ hiddenRows (Strings array) - optional, list of rows to be hidden 41 * regardless of the item edited or added by the dialog. 42 * Possible values: 43 * - "title" 44 * - "location" 45 * - "keyword" 46 * - "tags" 47 * - "folderPicker" - hides both the tree and the menu. 48 * 49 * window.arguments[0].bookmarkGuid is set to the guid of the item, if the 50 * dialog is accepted. 51 */ 52 53 /* import-globals-from editBookmark.js */ 54 55 /* Shared Places Import - change other consumers if you change this: */ 56 var { XPCOMUtils } = ChromeUtils.importESModule( 57 "resource://gre/modules/XPCOMUtils.sys.mjs" 58 ); 59 ChromeUtils.defineESModuleGetters(this, { 60 PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", 61 }); 62 XPCOMUtils.defineLazyScriptGetter( 63 this, 64 "PlacesTreeView", 65 "chrome://browser/content/places/treeView.js" 66 ); 67 XPCOMUtils.defineLazyScriptGetter( 68 this, 69 ["PlacesInsertionPoint", "PlacesController", "PlacesControllerDragHelper"], 70 "chrome://browser/content/places/controller.js" 71 ); 72 /* End Shared Places Import */ 73 74 const BOOKMARK_ITEM = 0; 75 const BOOKMARK_FOLDER = 1; 76 77 const ACTION_EDIT = 0; 78 const ACTION_ADD = 1; 79 80 var BookmarkPropertiesPanel = { 81 /** UI Text Strings */ 82 __strings: null, 83 get _strings() { 84 if (!this.__strings) { 85 this.__strings = document.getElementById("stringBundle"); 86 } 87 return this.__strings; 88 }, 89 90 _action: null, 91 _itemType: null, 92 _uri: null, 93 _title: "", 94 _URIs: [], 95 _keyword: "", 96 _postData: null, 97 _charSet: "", 98 99 _defaultInsertionPoint: null, 100 _hiddenRows: [], 101 102 /** 103 * @returns {string} 104 * This method returns the correct label for the dialog's "accept" 105 * button based on the variant of the dialog. 106 */ 107 _getAcceptLabel: function BPP__getAcceptLabel() { 108 return this._strings.getString("dialogAcceptLabelSaveItem"); 109 }, 110 111 /** 112 * @returns {string} 113 * This method returns the correct title for the current variant 114 * of this dialog. 115 */ 116 _getDialogTitle: function BPP__getDialogTitle() { 117 if (this._action == ACTION_ADD) { 118 if (this._itemType == BOOKMARK_ITEM) { 119 return this._strings.getString("dialogTitleAddNewBookmark2"); 120 } 121 122 // add folder 123 if (this._itemType != BOOKMARK_FOLDER) { 124 throw new Error("Unknown item type"); 125 } 126 if (this._URIs.length) { 127 return this._strings.getString("dialogTitleAddMulti"); 128 } 129 130 return this._strings.getString("dialogTitleAddBookmarkFolder"); 131 } 132 if (this._action == ACTION_EDIT) { 133 if (this._itemType === BOOKMARK_ITEM) { 134 return this._strings.getString("dialogTitleEditBookmark2"); 135 } 136 137 return this._strings.getString("dialogTitleEditBookmarkFolder"); 138 } 139 return ""; 140 }, 141 142 /** 143 * Determines the initial data for the item edited or added by this dialog 144 */ 145 async _determineItemInfo() { 146 let dialogInfo = window.arguments[0]; 147 this._action = dialogInfo.action == "add" ? ACTION_ADD : ACTION_EDIT; 148 this._hiddenRows = dialogInfo.hiddenRows ? dialogInfo.hiddenRows : []; 149 if (this._action == ACTION_ADD) { 150 if (!("type" in dialogInfo)) { 151 throw new Error("missing type property for add action"); 152 } 153 154 if ("title" in dialogInfo) { 155 this._title = dialogInfo.title; 156 } 157 158 if ("defaultInsertionPoint" in dialogInfo) { 159 this._defaultInsertionPoint = dialogInfo.defaultInsertionPoint; 160 } else { 161 let parentGuid = await PlacesUIUtils.defaultParentGuid; 162 this._defaultInsertionPoint = new PlacesInsertionPoint({ 163 parentGuid, 164 }); 165 } 166 167 switch (dialogInfo.type) { 168 case "bookmark": 169 this._itemType = BOOKMARK_ITEM; 170 if ("uri" in dialogInfo) { 171 if (!(dialogInfo.uri instanceof Ci.nsIURI)) { 172 throw new Error("uri property should be a uri object"); 173 } 174 this._uri = dialogInfo.uri; 175 if (typeof this._title != "string") { 176 this._title = 177 (await PlacesUtils.history.fetch(this._uri)) || this._uri.spec; 178 } 179 } else { 180 this._uri = Services.io.newURI("about:blank"); 181 this._title = this._strings.getString("newBookmarkDefault"); 182 this._dummyItem = true; 183 } 184 185 if ("keyword" in dialogInfo) { 186 this._keyword = dialogInfo.keyword; 187 this._isAddKeywordDialog = true; 188 if ("postData" in dialogInfo) { 189 this._postData = dialogInfo.postData; 190 } 191 if ("charSet" in dialogInfo) { 192 this._charSet = dialogInfo.charSet; 193 } 194 } 195 break; 196 197 case "folder": 198 this._itemType = BOOKMARK_FOLDER; 199 if (!this._title) { 200 if ("URIList" in dialogInfo) { 201 this._title = this._strings.getString("bookmarkAllTabsDefault"); 202 this._URIs = dialogInfo.URIList; 203 } else { 204 this._title = this._strings.getString("newFolderDefault"); 205 this._dummyItem = true; 206 } 207 } 208 break; 209 } 210 } else { 211 // edit 212 this._node = dialogInfo.node; 213 this._title = this._node.title; 214 if (PlacesUtils.nodeIsFolderOrShortcut(this._node)) { 215 this._itemType = BOOKMARK_FOLDER; 216 } else if (PlacesUtils.nodeIsURI(this._node)) { 217 this._itemType = BOOKMARK_ITEM; 218 } 219 } 220 }, 221 222 /** 223 * This method should be called by the onload of the Bookmark Properties 224 * dialog to initialize the state of the panel. 225 */ 226 async onDialogLoad() { 227 document.addEventListener("dialogaccept", () => this.onDialogAccept()); 228 document.addEventListener("dialogcancel", () => this.onDialogCancel()); 229 window.addEventListener("unload", () => this.onDialogUnload()); 230 231 // Disable the buttons until we have all the information required. 232 let acceptButton = document 233 .getElementById("bookmarkpropertiesdialog") 234 .getButton("accept"); 235 acceptButton.disabled = true; 236 await this._determineItemInfo(); 237 document.title = this._getDialogTitle(); 238 239 // Set adjustable title 240 let title = { raw: document.title }; 241 document.documentElement.setAttribute("headertitle", JSON.stringify(title)); 242 243 let iconUrl = this._getIconUrl(); 244 if (iconUrl) { 245 document.documentElement.style.setProperty( 246 "--icon-url", 247 `url(${iconUrl})` 248 ); 249 } 250 251 await this._initDialog(); 252 }, 253 254 _getIconUrl() { 255 let url = "chrome://browser/skin/bookmark-hollow.svg"; 256 257 if (this._action === ACTION_EDIT && this._itemType === BOOKMARK_ITEM) { 258 url = window.arguments[0]?.node?.icon; 259 } 260 261 return url; 262 }, 263 264 /** 265 * Initializes the dialog, gathering the required bookmark data. This function 266 * will enable the accept button (if appropraite) when it is complete. 267 */ 268 async _initDialog() { 269 let acceptButton = document 270 .getElementById("bookmarkpropertiesdialog") 271 .getButton("accept"); 272 acceptButton.label = this._getAcceptLabel(); 273 let acceptButtonDisabled = false; 274 275 // Since elements can be unhidden asynchronously, we must observe their 276 // mutations and resize the dialog accordingly. 277 this._mutationObserver = new MutationObserver(mutations => { 278 for (let { target, oldValue } of mutations) { 279 let hidden = target.hasAttribute("hidden"); 280 let wasHidden = oldValue !== null; 281 if (target.classList.contains("hideable") && hidden != wasHidden) { 282 // To support both kind of dialogs (window and dialog-box) we need 283 // both resizeBy and sizeToContent, otherwise either the dialog 284 // doesn't resize, or it gets empty unused space. 285 if (hidden) { 286 let diff = this._mutationObserver._heightsById.get(target.id); 287 window.resizeBy(0, -diff); 288 } else { 289 let diff = target.getBoundingClientRect().height; 290 this._mutationObserver._heightsById.set(target.id, diff); 291 window.resizeBy(0, diff); 292 } 293 window.sizeToContent(); 294 } 295 } 296 }); 297 this._mutationObserver._heightsById = new Map(); 298 this._mutationObserver.observe(document, { 299 subtree: true, 300 attributeOldValue: true, 301 attributeFilter: ["hidden"], 302 }); 303 304 switch (this._action) { 305 case ACTION_EDIT: { 306 await gEditItemOverlay.initPanel({ 307 node: this._node, 308 hiddenRows: this._hiddenRows, 309 focusedElement: "first", 310 }); 311 acceptButtonDisabled = gEditItemOverlay.readOnly; 312 break; 313 } 314 case ACTION_ADD: { 315 this._node = await this._promiseNewItem(); 316 317 // Edit the new item 318 await gEditItemOverlay.initPanel({ 319 node: this._node, 320 hiddenRows: this._hiddenRows, 321 postData: this._postData, 322 focusedElement: "first", 323 addedMultipleBookmarks: this._node.children?.length > 1, 324 }); 325 326 // Empty location field if the uri is about:blank, this way inserting a new 327 // url will be easier for the user, Accept button will be automatically 328 // disabled by the input listener until the user fills the field. 329 let locationField = this._element("locationField"); 330 if (locationField.value == "about:blank") { 331 locationField.value = ""; 332 } 333 334 // if this is an uri related dialog disable accept button until 335 // the user fills an uri value. 336 if (this._itemType == BOOKMARK_ITEM) { 337 acceptButtonDisabled = !this._inputIsValid(); 338 } 339 break; 340 } 341 } 342 343 if (!gEditItemOverlay.readOnly) { 344 // Listen on uri fields to enable accept button if input is valid 345 if (this._itemType == BOOKMARK_ITEM) { 346 this._element("locationField").addEventListener("input", this); 347 if (this._isAddKeywordDialog) { 348 this._element("keywordField").addEventListener("input", this); 349 } 350 } 351 } 352 // Only enable the accept button once we've finished everything. 353 acceptButton.disabled = acceptButtonDisabled; 354 }, 355 356 // EventListener 357 handleEvent: function BPP_handleEvent(aEvent) { 358 var target = aEvent.target; 359 switch (aEvent.type) { 360 case "input": 361 if ( 362 target.id == "editBMPanel_locationField" || 363 target.id == "editBMPanel_keywordField" 364 ) { 365 // Check uri fields to enable accept button if input is valid 366 document 367 .getElementById("bookmarkpropertiesdialog") 368 .getButton("accept").disabled = !this._inputIsValid(); 369 } 370 break; 371 } 372 }, 373 374 // nsISupports 375 QueryInterface: ChromeUtils.generateQI([]), 376 377 _element: function BPP__element(aID) { 378 return document.getElementById("editBMPanel_" + aID); 379 }, 380 381 onDialogUnload() { 382 // gEditItemOverlay does not exist anymore here, so don't rely on it. 383 this._mutationObserver.disconnect(); 384 delete this._mutationObserver; 385 386 // Calling removeEventListener with arguments which do not identify any 387 // currently registered EventListener on the EventTarget has no effect. 388 this._element("locationField").removeEventListener("input", this); 389 this._element("keywordField").removeEventListener("input", this); 390 }, 391 392 onDialogAccept() { 393 // We must blur current focused element to save its changes correctly 394 document.commandDispatcher.focusedElement?.blur(); 395 396 // Get the states to compare bookmark and editedBookmark 397 window.arguments[0].bookmarkState = gEditItemOverlay._bookmarkState; 398 399 // We have to uninit the panel first, otherwise late changes could force it 400 // to commit more transactions. 401 gEditItemOverlay.uninitPanel(true); 402 403 window.arguments[0].bookmarkGuid = this._node.bookmarkGuid; 404 }, 405 406 onDialogCancel() { 407 // We have to uninit the panel first, otherwise late changes could force it 408 // to commit more transactions. 409 gEditItemOverlay.uninitPanel(true); 410 }, 411 412 /** 413 * This method checks to see if the input fields are in a valid state. 414 * 415 * @returns {boolean} true if the input is valid, false otherwise 416 */ 417 _inputIsValid: function BPP__inputIsValid() { 418 if ( 419 this._itemType == BOOKMARK_ITEM && 420 !this._containsValidURI("locationField") 421 ) { 422 return false; 423 } 424 if ( 425 this._isAddKeywordDialog && 426 !this._element("keywordField").value.length 427 ) { 428 return false; 429 } 430 431 return true; 432 }, 433 434 /** 435 * Determines whether the input with the given ID contains a 436 * string that can be converted into an nsIURI. 437 * 438 * @param {number} aTextboxID 439 * the ID of the textbox element whose contents we'll test 440 * 441 * @returns {boolean} true if the textbox contains a valid URI string, false otherwise 442 */ 443 _containsValidURI: function BPP__containsValidURI(aTextboxID) { 444 try { 445 var value = this._element(aTextboxID).value; 446 if (value) { 447 Services.uriFixup.getFixupURIInfo(value); 448 return true; 449 } 450 } catch (e) {} 451 return false; 452 }, 453 454 /** 455 * [New Item Mode] Get the insertion point details for the new item, given 456 * dialog state and opening arguments. 457 * 458 * @returns {Array} 459 * The container-identifier and insertion-index are returned separately in 460 * the form of [containerIdentifier, insertionIndex] 461 */ 462 async _getInsertionPointDetails() { 463 return [ 464 await this._defaultInsertionPoint.getIndex(), 465 this._defaultInsertionPoint.guid, 466 ]; 467 }, 468 469 async _promiseNewItem() { 470 let [index, parentGuid] = await this._getInsertionPointDetails(); 471 472 let info = { parentGuid, index, title: this._title }; 473 if (this._itemType == BOOKMARK_ITEM) { 474 info.url = this._uri; 475 if (this._keyword) { 476 info.keyword = this._keyword; 477 } 478 if (this._postData) { 479 info.postData = this._postData; 480 } 481 482 if (this._charSet) { 483 PlacesUIUtils.setCharsetForPage(this._uri, this._charSet, window).catch( 484 console.error 485 ); 486 } 487 } else if (this._itemType == BOOKMARK_FOLDER) { 488 // NewFolder requires a url rather than uri. 489 info.children = this._URIs.map(item => { 490 return { url: item.uri, title: item.title }; 491 }); 492 } else { 493 throw new Error(`unexpected value for _itemType: ${this._itemType}`); 494 } 495 return Object.freeze({ 496 index, 497 bookmarkGuid: PlacesUtils.bookmarks.unsavedGuid, 498 title: this._title, 499 uri: this._uri ? this._uri.spec : "", 500 type: 501 this._itemType == BOOKMARK_ITEM 502 ? Ci.nsINavHistoryResultNode.RESULT_TYPE_URI 503 : Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER, 504 parent: { 505 bookmarkGuid: parentGuid, 506 type: Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER, 507 }, 508 children: info.children, 509 }); 510 }, 511 }; 512 513 document.addEventListener("DOMContentLoaded", function () { 514 // Content initialization is asynchronous, thus set mozSubdialogReady 515 // immediately to properly wait for it. 516 document.mozSubdialogReady = BookmarkPropertiesPanel.onDialogLoad() 517 .catch(ex => console.error(`Failed to initialize dialog: ${ex}`)) 518 .then(() => window.sizeToContent()); 519 });