1.1 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 1.2 +++ b/browser/devtools/shared/autocomplete-popup.js Wed Dec 31 06:09:35 2014 +0100 1.3 @@ -0,0 +1,567 @@ 1.4 +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ 1.5 +/* This Source Code Form is subject to the terms of the Mozilla Public 1.6 + * License, v. 2.0. If a copy of the MPL was not distributed with this 1.7 + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 1.8 + 1.9 +"use strict"; 1.10 + 1.11 +const {Cc, Ci, Cu} = require("chrome"); 1.12 +const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; 1.13 + 1.14 +loader.lazyImporter(this, "Services", "resource://gre/modules/Services.jsm"); 1.15 +loader.lazyImporter(this, "gDevTools", "resource:///modules/devtools/gDevTools.jsm"); 1.16 + 1.17 +/** 1.18 + * Autocomplete popup UI implementation. 1.19 + * 1.20 + * @constructor 1.21 + * @param nsIDOMDocument aDocument 1.22 + * The document you want the popup attached to. 1.23 + * @param Object aOptions 1.24 + * An object consiting any of the following options: 1.25 + * - panelId {String} The id for the popup panel. 1.26 + * - listBoxId {String} The id for the richlistbox inside the panel. 1.27 + * - position {String} The position for the popup panel. 1.28 + * - theme {String} String related to the theme of the popup. 1.29 + * - autoSelect {Boolean} Boolean to allow the first entry of the popup 1.30 + * panel to be automatically selected when the popup shows. 1.31 + * - direction {String} The direction of the text in the panel. rtl or ltr 1.32 + * - onSelect {String} The select event handler for the richlistbox 1.33 + * - onClick {String} The click event handler for the richlistbox. 1.34 + * - onKeypress {String} The keypress event handler for the richlistitems. 1.35 + */ 1.36 +function AutocompletePopup(aDocument, aOptions = {}) 1.37 +{ 1.38 + this._document = aDocument; 1.39 + 1.40 + this.autoSelect = aOptions.autoSelect || false; 1.41 + this.position = aOptions.position || "after_start"; 1.42 + this.direction = aOptions.direction || "ltr"; 1.43 + 1.44 + this.onSelect = aOptions.onSelect; 1.45 + this.onClick = aOptions.onClick; 1.46 + this.onKeypress = aOptions.onKeypress; 1.47 + 1.48 + let id = aOptions.panelId || "devtools_autoCompletePopup"; 1.49 + let theme = aOptions.theme || "dark"; 1.50 + // If theme is auto, use the devtools.theme pref 1.51 + if (theme == "auto") { 1.52 + theme = Services.prefs.getCharPref("devtools.theme"); 1.53 + this.autoThemeEnabled = true; 1.54 + // Setup theme change listener. 1.55 + this._handleThemeChange = this._handleThemeChange.bind(this); 1.56 + gDevTools.on("pref-changed", this._handleThemeChange); 1.57 + } 1.58 + // Reuse the existing popup elements. 1.59 + this._panel = this._document.getElementById(id); 1.60 + if (!this._panel) { 1.61 + this._panel = this._document.createElementNS(XUL_NS, "panel"); 1.62 + this._panel.setAttribute("id", id); 1.63 + this._panel.className = "devtools-autocomplete-popup devtools-monospace " 1.64 + + theme + "-theme"; 1.65 + 1.66 + this._panel.setAttribute("noautofocus", "true"); 1.67 + this._panel.setAttribute("level", "top"); 1.68 + if (!aOptions.onKeypress) { 1.69 + this._panel.setAttribute("ignorekeys", "true"); 1.70 + } 1.71 + 1.72 + let mainPopupSet = this._document.getElementById("mainPopupSet"); 1.73 + if (mainPopupSet) { 1.74 + mainPopupSet.appendChild(this._panel); 1.75 + } 1.76 + else { 1.77 + this._document.documentElement.appendChild(this._panel); 1.78 + } 1.79 + } 1.80 + else { 1.81 + this._list = this._panel.firstChild; 1.82 + } 1.83 + 1.84 + if (!this._list) { 1.85 + this._list = this._document.createElementNS(XUL_NS, "richlistbox"); 1.86 + this._panel.appendChild(this._list); 1.87 + 1.88 + // Open and hide the panel, so we initialize the API of the richlistbox. 1.89 + this._panel.openPopup(null, this.position, 0, 0); 1.90 + this._panel.hidePopup(); 1.91 + } 1.92 + 1.93 + this._list.setAttribute("flex", "1"); 1.94 + this._list.setAttribute("seltype", "single"); 1.95 + 1.96 + if (aOptions.listBoxId) { 1.97 + this._list.setAttribute("id", aOptions.listBoxId); 1.98 + } 1.99 + this._list.className = "devtools-autocomplete-listbox " + theme + "-theme"; 1.100 + 1.101 + if (this.onSelect) { 1.102 + this._list.addEventListener("select", this.onSelect, false); 1.103 + } 1.104 + 1.105 + if (this.onClick) { 1.106 + this._list.addEventListener("click", this.onClick, false); 1.107 + } 1.108 + 1.109 + if (this.onKeypress) { 1.110 + this._list.addEventListener("keypress", this.onKeypress, false); 1.111 + } 1.112 +} 1.113 +exports.AutocompletePopup = AutocompletePopup; 1.114 + 1.115 +AutocompletePopup.prototype = { 1.116 + _document: null, 1.117 + _panel: null, 1.118 + _list: null, 1.119 + __scrollbarWidth: null, 1.120 + 1.121 + // Event handlers. 1.122 + onSelect: null, 1.123 + onClick: null, 1.124 + onKeypress: null, 1.125 + 1.126 + /** 1.127 + * Open the autocomplete popup panel. 1.128 + * 1.129 + * @param nsIDOMNode aAnchor 1.130 + * Optional node to anchor the panel to. 1.131 + * @param Number aXOffset 1.132 + * Horizontal offset in pixels from the left of the node to the left 1.133 + * of the popup. 1.134 + * @param Number aYOffset 1.135 + * Vertical offset in pixels from the top of the node to the starting 1.136 + * of the popup. 1.137 + */ 1.138 + openPopup: function AP_openPopup(aAnchor, aXOffset = 0, aYOffset = 0) 1.139 + { 1.140 + this.__maxLabelLength = -1; 1.141 + this._updateSize(); 1.142 + this._panel.openPopup(aAnchor, this.position, aXOffset, aYOffset); 1.143 + 1.144 + if (this.autoSelect) { 1.145 + this.selectFirstItem(); 1.146 + } 1.147 + }, 1.148 + 1.149 + /** 1.150 + * Hide the autocomplete popup panel. 1.151 + */ 1.152 + hidePopup: function AP_hidePopup() 1.153 + { 1.154 + this._panel.hidePopup(); 1.155 + }, 1.156 + 1.157 + /** 1.158 + * Check if the autocomplete popup is open. 1.159 + */ 1.160 + get isOpen() { 1.161 + return this._panel.state == "open" || this._panel.state == "showing"; 1.162 + }, 1.163 + 1.164 + /** 1.165 + * Destroy the object instance. Please note that the panel DOM elements remain 1.166 + * in the DOM, because they might still be in use by other instances of the 1.167 + * same code. It is the responsability of the client code to perform DOM 1.168 + * cleanup. 1.169 + */ 1.170 + destroy: function AP_destroy() 1.171 + { 1.172 + if (this.isOpen) { 1.173 + this.hidePopup(); 1.174 + } 1.175 + this.clearItems(); 1.176 + 1.177 + if (this.onSelect) { 1.178 + this._list.removeEventListener("select", this.onSelect, false); 1.179 + } 1.180 + 1.181 + if (this.onClick) { 1.182 + this._list.removeEventListener("click", this.onClick, false); 1.183 + } 1.184 + 1.185 + if (this.onKeypress) { 1.186 + this._list.removeEventListener("keypress", this.onKeypress, false); 1.187 + } 1.188 + 1.189 + if (this.autoThemeEnabled) { 1.190 + gDevTools.off("pref-changed", this._handleThemeChange); 1.191 + } 1.192 + 1.193 + this._document = null; 1.194 + this._list = null; 1.195 + this._panel = null; 1.196 + }, 1.197 + 1.198 + /** 1.199 + * Get the autocomplete items array. 1.200 + * 1.201 + * @param Number aIndex The index of the item what is wanted. 1.202 + * 1.203 + * @return The autocomplete item at index aIndex. 1.204 + */ 1.205 + getItemAtIndex: function AP_getItemAtIndex(aIndex) 1.206 + { 1.207 + return this._list.getItemAtIndex(aIndex)._autocompleteItem; 1.208 + }, 1.209 + 1.210 + /** 1.211 + * Get the autocomplete items array. 1.212 + * 1.213 + * @return array 1.214 + * The array of autocomplete items. 1.215 + */ 1.216 + getItems: function AP_getItems() 1.217 + { 1.218 + let items = []; 1.219 + 1.220 + Array.forEach(this._list.childNodes, function(aItem) { 1.221 + items.push(aItem._autocompleteItem); 1.222 + }); 1.223 + 1.224 + return items; 1.225 + }, 1.226 + 1.227 + /** 1.228 + * Set the autocomplete items list, in one go. 1.229 + * 1.230 + * @param array aItems 1.231 + * The list of items you want displayed in the popup list. 1.232 + */ 1.233 + setItems: function AP_setItems(aItems) 1.234 + { 1.235 + this.clearItems(); 1.236 + aItems.forEach(this.appendItem, this); 1.237 + 1.238 + // Make sure that the new content is properly fitted by the XUL richlistbox. 1.239 + if (this.isOpen) { 1.240 + if (this.autoSelect) { 1.241 + this.selectFirstItem(); 1.242 + } 1.243 + this._updateSize(); 1.244 + } 1.245 + }, 1.246 + 1.247 + /** 1.248 + * Selects the first item of the richlistbox. Note that first item here is the 1.249 + * item closes to the input element, which means that 0th index if position is 1.250 + * below, and last index if position is above. 1.251 + */ 1.252 + selectFirstItem: function AP_selectFirstItem() 1.253 + { 1.254 + if (this.position.contains("before")) { 1.255 + this.selectedIndex = this.itemCount - 1; 1.256 + } 1.257 + else { 1.258 + this.selectedIndex = 0; 1.259 + } 1.260 + this._list.ensureIndexIsVisible(this._list.selectedIndex); 1.261 + }, 1.262 + 1.263 + __maxLabelLength: -1, 1.264 + 1.265 + get _maxLabelLength() { 1.266 + if (this.__maxLabelLength != -1) { 1.267 + return this.__maxLabelLength; 1.268 + } 1.269 + 1.270 + let max = 0; 1.271 + for (let i = 0; i < this._list.childNodes.length; i++) { 1.272 + let item = this._list.childNodes[i]._autocompleteItem; 1.273 + let str = item.label; 1.274 + if (item.count) { 1.275 + str += (item.count + ""); 1.276 + } 1.277 + max = Math.max(str.length, max); 1.278 + } 1.279 + 1.280 + this.__maxLabelLength = max; 1.281 + return this.__maxLabelLength; 1.282 + }, 1.283 + 1.284 + /** 1.285 + * Update the panel size to fit the content. 1.286 + * 1.287 + * @private 1.288 + */ 1.289 + _updateSize: function AP__updateSize() 1.290 + { 1.291 + if (!this._panel) { 1.292 + return; 1.293 + } 1.294 + 1.295 + this._list.style.width = (this._maxLabelLength + 3) +"ch"; 1.296 + this._list.ensureIndexIsVisible(this._list.selectedIndex); 1.297 + }, 1.298 + 1.299 + /** 1.300 + * Clear all the items from the autocomplete list. 1.301 + */ 1.302 + clearItems: function AP_clearItems() 1.303 + { 1.304 + // Reset the selectedIndex to -1 before clearing the list 1.305 + this.selectedIndex = -1; 1.306 + 1.307 + while (this._list.hasChildNodes()) { 1.308 + this._list.removeChild(this._list.firstChild); 1.309 + } 1.310 + 1.311 + this.__maxLabelLength = -1; 1.312 + 1.313 + // Reset the panel and list dimensions. New dimensions are calculated when 1.314 + // a new set of items is added to the autocomplete popup. 1.315 + this._list.width = ""; 1.316 + this._list.style.width = ""; 1.317 + this._list.height = ""; 1.318 + this._panel.width = ""; 1.319 + this._panel.height = ""; 1.320 + this._panel.top = ""; 1.321 + this._panel.left = ""; 1.322 + }, 1.323 + 1.324 + /** 1.325 + * Getter for the index of the selected item. 1.326 + * 1.327 + * @type number 1.328 + */ 1.329 + get selectedIndex() { 1.330 + return this._list.selectedIndex; 1.331 + }, 1.332 + 1.333 + /** 1.334 + * Setter for the selected index. 1.335 + * 1.336 + * @param number aIndex 1.337 + * The number (index) of the item you want to select in the list. 1.338 + */ 1.339 + set selectedIndex(aIndex) { 1.340 + this._list.selectedIndex = aIndex; 1.341 + if (this.isOpen && this._list.ensureIndexIsVisible) { 1.342 + this._list.ensureIndexIsVisible(this._list.selectedIndex); 1.343 + } 1.344 + }, 1.345 + 1.346 + /** 1.347 + * Getter for the selected item. 1.348 + * @type object 1.349 + */ 1.350 + get selectedItem() { 1.351 + return this._list.selectedItem ? 1.352 + this._list.selectedItem._autocompleteItem : null; 1.353 + }, 1.354 + 1.355 + /** 1.356 + * Setter for the selected item. 1.357 + * 1.358 + * @param object aItem 1.359 + * The object you want selected in the list. 1.360 + */ 1.361 + set selectedItem(aItem) { 1.362 + this._list.selectedItem = this._findListItem(aItem); 1.363 + if (this.isOpen) { 1.364 + this._list.ensureIndexIsVisible(this._list.selectedIndex); 1.365 + } 1.366 + }, 1.367 + 1.368 + /** 1.369 + * Append an item into the autocomplete list. 1.370 + * 1.371 + * @param object aItem 1.372 + * The item you want appended to the list. 1.373 + * The item object can have the following properties: 1.374 + * - label {String} Property which is used as the displayed value. 1.375 + * - preLabel {String} [Optional] The String that will be displayed 1.376 + * before the label indicating that this is the already 1.377 + * present text in the input box, and label is the text 1.378 + * that will be auto completed. When this property is 1.379 + * present, |preLabel.length| starting characters will be 1.380 + * removed from label. 1.381 + * - count {Number} [Optional] The number to represent the count of 1.382 + * autocompleted label. 1.383 + */ 1.384 + appendItem: function AP_appendItem(aItem) 1.385 + { 1.386 + let listItem = this._document.createElementNS(XUL_NS, "richlistitem"); 1.387 + if (this.direction) { 1.388 + listItem.setAttribute("dir", this.direction); 1.389 + } 1.390 + let label = this._document.createElementNS(XUL_NS, "label"); 1.391 + label.setAttribute("value", aItem.label); 1.392 + label.setAttribute("class", "autocomplete-value"); 1.393 + if (aItem.preLabel) { 1.394 + let preDesc = this._document.createElementNS(XUL_NS, "label"); 1.395 + preDesc.setAttribute("value", aItem.preLabel); 1.396 + preDesc.setAttribute("class", "initial-value"); 1.397 + listItem.appendChild(preDesc); 1.398 + label.setAttribute("value", aItem.label.slice(aItem.preLabel.length)); 1.399 + } 1.400 + listItem.appendChild(label); 1.401 + if (aItem.count && aItem.count > 1) { 1.402 + let countDesc = this._document.createElementNS(XUL_NS, "label"); 1.403 + countDesc.setAttribute("value", aItem.count); 1.404 + countDesc.setAttribute("flex", "1"); 1.405 + countDesc.setAttribute("class", "autocomplete-count"); 1.406 + listItem.appendChild(countDesc); 1.407 + } 1.408 + listItem._autocompleteItem = aItem; 1.409 + 1.410 + this._list.appendChild(listItem); 1.411 + }, 1.412 + 1.413 + /** 1.414 + * Find the richlistitem element that belongs to an item. 1.415 + * 1.416 + * @private 1.417 + * 1.418 + * @param object aItem 1.419 + * The object you want found in the list. 1.420 + * 1.421 + * @return nsIDOMNode|null 1.422 + * The nsIDOMNode that belongs to the given item object. This node is 1.423 + * the richlistitem element. 1.424 + */ 1.425 + _findListItem: function AP__findListItem(aItem) 1.426 + { 1.427 + for (let i = 0; i < this._list.childNodes.length; i++) { 1.428 + let child = this._list.childNodes[i]; 1.429 + if (child._autocompleteItem == aItem) { 1.430 + return child; 1.431 + } 1.432 + } 1.433 + return null; 1.434 + }, 1.435 + 1.436 + /** 1.437 + * Remove an item from the popup list. 1.438 + * 1.439 + * @param object aItem 1.440 + * The item you want removed. 1.441 + */ 1.442 + removeItem: function AP_removeItem(aItem) 1.443 + { 1.444 + let item = this._findListItem(aItem); 1.445 + if (!item) { 1.446 + throw new Error("Item not found!"); 1.447 + } 1.448 + this._list.removeChild(item); 1.449 + }, 1.450 + 1.451 + /** 1.452 + * Getter for the number of items in the popup. 1.453 + * @type number 1.454 + */ 1.455 + get itemCount() { 1.456 + return this._list.childNodes.length; 1.457 + }, 1.458 + 1.459 + /** 1.460 + * Getter for the height of each item in the list. 1.461 + * 1.462 + * @private 1.463 + * 1.464 + * @type number 1.465 + */ 1.466 + get _itemHeight() { 1.467 + return this._list.selectedItem.clientHeight; 1.468 + }, 1.469 + 1.470 + /** 1.471 + * Select the next item in the list. 1.472 + * 1.473 + * @return object 1.474 + * The newly selected item object. 1.475 + */ 1.476 + selectNextItem: function AP_selectNextItem() 1.477 + { 1.478 + if (this.selectedIndex < (this.itemCount - 1)) { 1.479 + this.selectedIndex++; 1.480 + } 1.481 + else { 1.482 + this.selectedIndex = 0; 1.483 + } 1.484 + 1.485 + return this.selectedItem; 1.486 + }, 1.487 + 1.488 + /** 1.489 + * Select the previous item in the list. 1.490 + * 1.491 + * @return object 1.492 + * The newly-selected item object. 1.493 + */ 1.494 + selectPreviousItem: function AP_selectPreviousItem() 1.495 + { 1.496 + if (this.selectedIndex > 0) { 1.497 + this.selectedIndex--; 1.498 + } 1.499 + else { 1.500 + this.selectedIndex = this.itemCount - 1; 1.501 + } 1.502 + 1.503 + return this.selectedItem; 1.504 + }, 1.505 + 1.506 + /** 1.507 + * Select the top-most item in the next page of items or 1.508 + * the last item in the list. 1.509 + * 1.510 + * @return object 1.511 + * The newly-selected item object. 1.512 + */ 1.513 + selectNextPageItem: function AP_selectNextPageItem() 1.514 + { 1.515 + let itemsPerPane = Math.floor(this._list.scrollHeight / this._itemHeight); 1.516 + let nextPageIndex = this.selectedIndex + itemsPerPane + 1; 1.517 + this.selectedIndex = nextPageIndex > this.itemCount - 1 ? 1.518 + this.itemCount - 1 : nextPageIndex; 1.519 + 1.520 + return this.selectedItem; 1.521 + }, 1.522 + 1.523 + /** 1.524 + * Select the bottom-most item in the previous page of items, 1.525 + * or the first item in the list. 1.526 + * 1.527 + * @return object 1.528 + * The newly-selected item object. 1.529 + */ 1.530 + selectPreviousPageItem: function AP_selectPreviousPageItem() 1.531 + { 1.532 + let itemsPerPane = Math.floor(this._list.scrollHeight / this._itemHeight); 1.533 + let prevPageIndex = this.selectedIndex - itemsPerPane - 1; 1.534 + this.selectedIndex = prevPageIndex < 0 ? 0 : prevPageIndex; 1.535 + 1.536 + return this.selectedItem; 1.537 + }, 1.538 + 1.539 + /** 1.540 + * Focuses the richlistbox. 1.541 + */ 1.542 + focus: function AP_focus() 1.543 + { 1.544 + this._list.focus(); 1.545 + }, 1.546 + 1.547 + /** 1.548 + * Manages theme switching for the popup based on the devtools.theme pref. 1.549 + * 1.550 + * @private 1.551 + * 1.552 + * @param String aEvent 1.553 + * The name of the event. In this case, "pref-changed". 1.554 + * @param Object aData 1.555 + * An object passed by the emitter of the event. In this case, the 1.556 + * object consists of three properties: 1.557 + * - pref {String} The name of the preference that was modified. 1.558 + * - newValue {Object} The new value of the preference. 1.559 + * - oldValue {Object} The old value of the preference. 1.560 + */ 1.561 + _handleThemeChange: function AP__handleThemeChange(aEvent, aData) 1.562 + { 1.563 + if (aData.pref == "devtools.theme") { 1.564 + this._panel.classList.toggle(aData.oldValue + "-theme", false); 1.565 + this._panel.classList.toggle(aData.newValue + "-theme", true); 1.566 + this._list.classList.toggle(aData.oldValue + "-theme", false); 1.567 + this._list.classList.toggle(aData.newValue + "-theme", true); 1.568 + } 1.569 + }, 1.570 +};