1.1 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 1.2 +++ b/browser/devtools/inspector/selector-search.js Wed Dec 31 06:09:35 2014 +0100 1.3 @@ -0,0 +1,492 @@ 1.4 +/* This Source Code Form is subject to the terms of the Mozilla Public 1.5 + * License, v. 2.0. If a copy of the MPL was not distributed with this 1.6 + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 1.7 + 1.8 +"use strict"; 1.9 + 1.10 +const promise = require("devtools/toolkit/deprecated-sync-thenables"); 1.11 + 1.12 +loader.lazyGetter(this, "AutocompletePopup", () => require("devtools/shared/autocomplete-popup").AutocompletePopup); 1.13 + 1.14 +// Maximum number of selector suggestions shown in the panel. 1.15 +const MAX_SUGGESTIONS = 15; 1.16 + 1.17 +/** 1.18 + * Converts any input box on a page to a CSS selector search and suggestion box. 1.19 + * 1.20 + * @constructor 1.21 + * @param InspectorPanel aInspector 1.22 + * The InspectorPanel whose `walker` attribute should be used for 1.23 + * document traversal. 1.24 + * @param nsiInputElement aInputNode 1.25 + * The input element to which the panel will be attached and from where 1.26 + * search input will be taken. 1.27 + */ 1.28 +function SelectorSearch(aInspector, aInputNode) { 1.29 + this.inspector = aInspector; 1.30 + this.searchBox = aInputNode; 1.31 + this.panelDoc = this.searchBox.ownerDocument; 1.32 + 1.33 + // initialize variables. 1.34 + this._lastSearched = null; 1.35 + this._lastValidSearch = ""; 1.36 + this._lastToLastValidSearch = null; 1.37 + this._searchResults = null; 1.38 + this._searchSuggestions = {}; 1.39 + this._searchIndex = 0; 1.40 + 1.41 + // bind! 1.42 + this._showPopup = this._showPopup.bind(this); 1.43 + this._onHTMLSearch = this._onHTMLSearch.bind(this); 1.44 + this._onSearchKeypress = this._onSearchKeypress.bind(this); 1.45 + this._onListBoxKeypress = this._onListBoxKeypress.bind(this); 1.46 + 1.47 + // Options for the AutocompletePopup. 1.48 + let options = { 1.49 + panelId: "inspector-searchbox-panel", 1.50 + listBoxId: "searchbox-panel-listbox", 1.51 + autoSelect: true, 1.52 + position: "before_start", 1.53 + direction: "ltr", 1.54 + theme: "auto", 1.55 + onClick: this._onListBoxKeypress, 1.56 + onKeypress: this._onListBoxKeypress 1.57 + }; 1.58 + this.searchPopup = new AutocompletePopup(this.panelDoc, options); 1.59 + 1.60 + // event listeners. 1.61 + this.searchBox.addEventListener("command", this._onHTMLSearch, true); 1.62 + this.searchBox.addEventListener("keypress", this._onSearchKeypress, true); 1.63 + 1.64 + // For testing, we need to be able to wait for the most recent node request 1.65 + // to finish. Tests can watch this promise for that. 1.66 + this._lastQuery = promise.resolve(null); 1.67 +} 1.68 + 1.69 +exports.SelectorSearch = SelectorSearch; 1.70 + 1.71 +SelectorSearch.prototype = { 1.72 + 1.73 + get walker() this.inspector.walker, 1.74 + 1.75 + // The possible states of the query. 1.76 + States: { 1.77 + CLASS: "class", 1.78 + ID: "id", 1.79 + TAG: "tag", 1.80 + }, 1.81 + 1.82 + // The current state of the query. 1.83 + _state: null, 1.84 + 1.85 + // The query corresponding to last state computation. 1.86 + _lastStateCheckAt: null, 1.87 + 1.88 + /** 1.89 + * Computes the state of the query. State refers to whether the query 1.90 + * currently requires a class suggestion, or a tag, or an Id suggestion. 1.91 + * This getter will effectively compute the state by traversing the query 1.92 + * character by character each time the query changes. 1.93 + * 1.94 + * @example 1.95 + * '#f' requires an Id suggestion, so the state is States.ID 1.96 + * 'div > .foo' requires class suggestion, so state is States.CLASS 1.97 + */ 1.98 + get state() { 1.99 + if (!this.searchBox || !this.searchBox.value) { 1.100 + return null; 1.101 + } 1.102 + 1.103 + let query = this.searchBox.value; 1.104 + if (this._lastStateCheckAt == query) { 1.105 + // If query is the same, return early. 1.106 + return this._state; 1.107 + } 1.108 + this._lastStateCheckAt = query; 1.109 + 1.110 + this._state = null; 1.111 + let subQuery = ""; 1.112 + // Now we iterate over the query and decide the state character by character. 1.113 + // The logic here is that while iterating, the state can go from one to 1.114 + // another with some restrictions. Like, if the state is Class, then it can 1.115 + // never go to Tag state without a space or '>' character; Or like, a Class 1.116 + // state with only '.' cannot go to an Id state without any [a-zA-Z] after 1.117 + // the '.' which means that '.#' is a selector matching a class name '#'. 1.118 + // Similarily for '#.' which means a selctor matching an id '.'. 1.119 + for (let i = 1; i <= query.length; i++) { 1.120 + // Calculate the state. 1.121 + subQuery = query.slice(0, i); 1.122 + let [secondLastChar, lastChar] = subQuery.slice(-2); 1.123 + switch (this._state) { 1.124 + case null: 1.125 + // This will happen only in the first iteration of the for loop. 1.126 + lastChar = secondLastChar; 1.127 + case this.States.TAG: 1.128 + this._state = lastChar == "." 1.129 + ? this.States.CLASS 1.130 + : lastChar == "#" 1.131 + ? this.States.ID 1.132 + : this.States.TAG; 1.133 + break; 1.134 + 1.135 + case this.States.CLASS: 1.136 + if (subQuery.match(/[\.]+[^\.]*$/)[0].length > 2) { 1.137 + // Checks whether the subQuery has atleast one [a-zA-Z] after the '.'. 1.138 + this._state = (lastChar == " " || lastChar == ">") 1.139 + ? this.States.TAG 1.140 + : lastChar == "#" 1.141 + ? this.States.ID 1.142 + : this.States.CLASS; 1.143 + } 1.144 + break; 1.145 + 1.146 + case this.States.ID: 1.147 + if (subQuery.match(/[#]+[^#]*$/)[0].length > 2) { 1.148 + // Checks whether the subQuery has atleast one [a-zA-Z] after the '#'. 1.149 + this._state = (lastChar == " " || lastChar == ">") 1.150 + ? this.States.TAG 1.151 + : lastChar == "." 1.152 + ? this.States.CLASS 1.153 + : this.States.ID; 1.154 + } 1.155 + break; 1.156 + } 1.157 + } 1.158 + return this._state; 1.159 + }, 1.160 + 1.161 + /** 1.162 + * Removes event listeners and cleans up references. 1.163 + */ 1.164 + destroy: function() { 1.165 + // event listeners. 1.166 + this.searchBox.removeEventListener("command", this._onHTMLSearch, true); 1.167 + this.searchBox.removeEventListener("keypress", this._onSearchKeypress, true); 1.168 + this.searchPopup.destroy(); 1.169 + this.searchPopup = null; 1.170 + this.searchBox = null; 1.171 + this.panelDoc = null; 1.172 + this._searchResults = null; 1.173 + this._searchSuggestions = null; 1.174 + }, 1.175 + 1.176 + _selectResult: function(index) { 1.177 + return this._searchResults.item(index).then(node => { 1.178 + this.inspector.selection.setNodeFront(node, "selectorsearch"); 1.179 + }); 1.180 + }, 1.181 + 1.182 + /** 1.183 + * The command callback for the input box. This function is automatically 1.184 + * invoked as the user is typing if the input box type is search. 1.185 + */ 1.186 + _onHTMLSearch: function() { 1.187 + let query = this.searchBox.value; 1.188 + if (query == this._lastSearched) { 1.189 + return; 1.190 + } 1.191 + this._lastSearched = query; 1.192 + this._searchResults = []; 1.193 + this._searchIndex = 0; 1.194 + 1.195 + if (query.length == 0) { 1.196 + this._lastValidSearch = ""; 1.197 + this.searchBox.removeAttribute("filled"); 1.198 + this.searchBox.classList.remove("devtools-no-search-result"); 1.199 + if (this.searchPopup.isOpen) { 1.200 + this.searchPopup.hidePopup(); 1.201 + } 1.202 + return; 1.203 + } 1.204 + 1.205 + this.searchBox.setAttribute("filled", true); 1.206 + let queryList = null; 1.207 + 1.208 + this._lastQuery = this.walker.querySelectorAll(this.walker.rootNode, query).then(list => { 1.209 + return list; 1.210 + }, (err) => { 1.211 + // Failures are ok here, just use a null item list; 1.212 + return null; 1.213 + }).then(queryList => { 1.214 + // Value has changed since we started this request, we're done. 1.215 + if (query != this.searchBox.value) { 1.216 + if (queryList) { 1.217 + queryList.release(); 1.218 + } 1.219 + return promise.reject(null); 1.220 + } 1.221 + 1.222 + this._searchResults = queryList || []; 1.223 + if (this._searchResults && this._searchResults.length > 0) { 1.224 + this._lastValidSearch = query; 1.225 + // Even though the selector matched atleast one node, there is still 1.226 + // possibility of suggestions. 1.227 + if (query.match(/[\s>+]$/)) { 1.228 + // If the query has a space or '>' at the end, create a selector to match 1.229 + // the children of the selector inside the search box by adding a '*'. 1.230 + this._lastValidSearch += "*"; 1.231 + } 1.232 + else if (query.match(/[\s>+][\.#a-zA-Z][\.#>\s+]*$/)) { 1.233 + // If the query is a partial descendant selector which does not matches 1.234 + // any node, remove the last incomplete part and add a '*' to match 1.235 + // everything. For ex, convert 'foo > b' to 'foo > *' . 1.236 + let lastPart = query.match(/[\s>+][\.#a-zA-Z][^>\s+]*$/)[0]; 1.237 + this._lastValidSearch = query.slice(0, -1 * lastPart.length + 1) + "*"; 1.238 + } 1.239 + 1.240 + if (!query.slice(-1).match(/[\.#\s>+]/)) { 1.241 + // Hide the popup if we have some matching nodes and the query is not 1.242 + // ending with [.# >] which means that the selector is not at the 1.243 + // beginning of a new class, tag or id. 1.244 + if (this.searchPopup.isOpen) { 1.245 + this.searchPopup.hidePopup(); 1.246 + } 1.247 + this.searchBox.classList.remove("devtools-no-search-result"); 1.248 + 1.249 + return this._selectResult(0); 1.250 + } 1.251 + return this._selectResult(0).then(() => { 1.252 + this.searchBox.classList.remove("devtools-no-search-result"); 1.253 + }).then(() => this.showSuggestions()); 1.254 + } 1.255 + if (query.match(/[\s>+]$/)) { 1.256 + this._lastValidSearch = query + "*"; 1.257 + } 1.258 + else if (query.match(/[\s>+][\.#a-zA-Z][\.#>\s+]*$/)) { 1.259 + let lastPart = query.match(/[\s+>][\.#a-zA-Z][^>\s+]*$/)[0]; 1.260 + this._lastValidSearch = query.slice(0, -1 * lastPart.length + 1) + "*"; 1.261 + } 1.262 + this.searchBox.classList.add("devtools-no-search-result"); 1.263 + return this.showSuggestions(); 1.264 + }); 1.265 + }, 1.266 + 1.267 + /** 1.268 + * Handles keypresses inside the input box. 1.269 + */ 1.270 + _onSearchKeypress: function(aEvent) { 1.271 + let query = this.searchBox.value; 1.272 + switch(aEvent.keyCode) { 1.273 + case aEvent.DOM_VK_RETURN: 1.274 + if (query == this._lastSearched && this._searchResults) { 1.275 + this._searchIndex = (this._searchIndex + 1) % this._searchResults.length; 1.276 + } 1.277 + else { 1.278 + this._onHTMLSearch(); 1.279 + return; 1.280 + } 1.281 + break; 1.282 + 1.283 + case aEvent.DOM_VK_UP: 1.284 + if (this.searchPopup.isOpen && this.searchPopup.itemCount > 0) { 1.285 + this.searchPopup.focus(); 1.286 + if (this.searchPopup.selectedIndex == this.searchPopup.itemCount - 1) { 1.287 + this.searchPopup.selectedIndex = 1.288 + Math.max(0, this.searchPopup.itemCount - 2); 1.289 + } 1.290 + else { 1.291 + this.searchPopup.selectedIndex = this.searchPopup.itemCount - 1; 1.292 + } 1.293 + this.searchBox.value = this.searchPopup.selectedItem.label; 1.294 + } 1.295 + else if (--this._searchIndex < 0) { 1.296 + this._searchIndex = this._searchResults.length - 1; 1.297 + } 1.298 + break; 1.299 + 1.300 + case aEvent.DOM_VK_DOWN: 1.301 + if (this.searchPopup.isOpen && this.searchPopup.itemCount > 0) { 1.302 + this.searchPopup.focus(); 1.303 + this.searchPopup.selectedIndex = 0; 1.304 + this.searchBox.value = this.searchPopup.selectedItem.label; 1.305 + } 1.306 + this._searchIndex = (this._searchIndex + 1) % this._searchResults.length; 1.307 + break; 1.308 + 1.309 + case aEvent.DOM_VK_TAB: 1.310 + if (this.searchPopup.isOpen && 1.311 + this.searchPopup.getItemAtIndex(this.searchPopup.itemCount - 1) 1.312 + .preLabel == query) { 1.313 + this.searchPopup.selectedIndex = this.searchPopup.itemCount - 1; 1.314 + this.searchBox.value = this.searchPopup.selectedItem.label; 1.315 + this._onHTMLSearch(); 1.316 + } 1.317 + break; 1.318 + 1.319 + case aEvent.DOM_VK_BACK_SPACE: 1.320 + case aEvent.DOM_VK_DELETE: 1.321 + // need to throw away the lastValidSearch. 1.322 + this._lastToLastValidSearch = null; 1.323 + // This gets the most complete selector from the query. For ex. 1.324 + // '.foo.ba' returns '.foo' , '#foo > .bar.baz' returns '#foo > .bar' 1.325 + // '.foo +bar' returns '.foo +' and likewise. 1.326 + this._lastValidSearch = (query.match(/(.*)[\.#][^\.# ]{0,}$/) || 1.327 + query.match(/(.*[\s>+])[a-zA-Z][^\.# ]{0,}$/) || 1.328 + ["",""])[1]; 1.329 + return; 1.330 + 1.331 + default: 1.332 + return; 1.333 + } 1.334 + 1.335 + aEvent.preventDefault(); 1.336 + aEvent.stopPropagation(); 1.337 + if (this._searchResults && this._searchResults.length > 0) { 1.338 + this._lastQuery = this._selectResult(this._searchIndex); 1.339 + } 1.340 + }, 1.341 + 1.342 + /** 1.343 + * Handles keypress and mouse click on the suggestions richlistbox. 1.344 + */ 1.345 + _onListBoxKeypress: function(aEvent) { 1.346 + switch(aEvent.keyCode || aEvent.button) { 1.347 + case aEvent.DOM_VK_RETURN: 1.348 + case aEvent.DOM_VK_TAB: 1.349 + case 0: // left mouse button 1.350 + aEvent.stopPropagation(); 1.351 + aEvent.preventDefault(); 1.352 + this.searchBox.value = this.searchPopup.selectedItem.label; 1.353 + this.searchBox.focus(); 1.354 + this._onHTMLSearch(); 1.355 + break; 1.356 + 1.357 + case aEvent.DOM_VK_UP: 1.358 + if (this.searchPopup.selectedIndex == 0) { 1.359 + this.searchPopup.selectedIndex = -1; 1.360 + aEvent.stopPropagation(); 1.361 + aEvent.preventDefault(); 1.362 + this.searchBox.focus(); 1.363 + } 1.364 + else { 1.365 + let index = this.searchPopup.selectedIndex; 1.366 + this.searchBox.value = this.searchPopup.getItemAtIndex(index - 1).label; 1.367 + } 1.368 + break; 1.369 + 1.370 + case aEvent.DOM_VK_DOWN: 1.371 + if (this.searchPopup.selectedIndex == this.searchPopup.itemCount - 1) { 1.372 + this.searchPopup.selectedIndex = -1; 1.373 + aEvent.stopPropagation(); 1.374 + aEvent.preventDefault(); 1.375 + this.searchBox.focus(); 1.376 + } 1.377 + else { 1.378 + let index = this.searchPopup.selectedIndex; 1.379 + this.searchBox.value = this.searchPopup.getItemAtIndex(index + 1).label; 1.380 + } 1.381 + break; 1.382 + 1.383 + case aEvent.DOM_VK_BACK_SPACE: 1.384 + aEvent.stopPropagation(); 1.385 + aEvent.preventDefault(); 1.386 + this.searchBox.focus(); 1.387 + if (this.searchBox.selectionStart > 0) { 1.388 + this.searchBox.value = 1.389 + this.searchBox.value.substring(0, this.searchBox.selectionStart - 1); 1.390 + } 1.391 + this._lastToLastValidSearch = null; 1.392 + let query = this.searchBox.value; 1.393 + this._lastValidSearch = (query.match(/(.*)[\.#][^\.# ]{0,}$/) || 1.394 + query.match(/(.*[\s>+])[a-zA-Z][^\.# ]{0,}$/) || 1.395 + ["",""])[1]; 1.396 + this._onHTMLSearch(); 1.397 + break; 1.398 + } 1.399 + }, 1.400 + 1.401 + /** 1.402 + * Populates the suggestions list and show the suggestion popup. 1.403 + */ 1.404 + _showPopup: function(aList, aFirstPart) { 1.405 + let total = 0; 1.406 + let query = this.searchBox.value; 1.407 + let toLowerCase = false; 1.408 + let items = []; 1.409 + // In case of tagNames, change the case to small. 1.410 + if (query.match(/.*[\.#][^\.#]{0,}$/) == null) { 1.411 + toLowerCase = true; 1.412 + } 1.413 + for (let [value, count] of aList) { 1.414 + // for cases like 'div ' or 'div >' or 'div+' 1.415 + if (query.match(/[\s>+]$/)) { 1.416 + value = query + value; 1.417 + } 1.418 + // for cases like 'div #a' or 'div .a' or 'div > d' and likewise 1.419 + else if (query.match(/[\s>+][\.#a-zA-Z][^\s>+\.#]*$/)) { 1.420 + let lastPart = query.match(/[\s>+][\.#a-zA-Z][^>\s+\.#]*$/)[0]; 1.421 + value = query.slice(0, -1 * lastPart.length + 1) + value; 1.422 + } 1.423 + // for cases like 'div.class' or '#foo.bar' and likewise 1.424 + else if (query.match(/[a-zA-Z][#\.][^#\.\s+>]*$/)) { 1.425 + let lastPart = query.match(/[a-zA-Z][#\.][^#\.\s>+]*$/)[0]; 1.426 + value = query.slice(0, -1 * lastPart.length + 1) + value; 1.427 + } 1.428 + let item = { 1.429 + preLabel: query, 1.430 + label: value, 1.431 + count: count 1.432 + }; 1.433 + if (toLowerCase) { 1.434 + item.label = value.toLowerCase(); 1.435 + } 1.436 + items.unshift(item); 1.437 + if (++total > MAX_SUGGESTIONS - 1) { 1.438 + break; 1.439 + } 1.440 + } 1.441 + if (total > 0) { 1.442 + this.searchPopup.setItems(items); 1.443 + this.searchPopup.openPopup(this.searchBox); 1.444 + } 1.445 + else { 1.446 + this.searchPopup.hidePopup(); 1.447 + } 1.448 + }, 1.449 + 1.450 + /** 1.451 + * Suggests classes,ids and tags based on the user input as user types in the 1.452 + * searchbox. 1.453 + */ 1.454 + showSuggestions: function() { 1.455 + let query = this.searchBox.value; 1.456 + let firstPart = ""; 1.457 + if (this.state == this.States.TAG) { 1.458 + // gets the tag that is being completed. For ex. 'div.foo > s' returns 's', 1.459 + // 'di' returns 'di' and likewise. 1.460 + firstPart = (query.match(/[\s>+]?([a-zA-Z]*)$/) || ["", query])[1]; 1.461 + query = query.slice(0, query.length - firstPart.length); 1.462 + } 1.463 + else if (this.state == this.States.CLASS) { 1.464 + // gets the class that is being completed. For ex. '.foo.b' returns 'b' 1.465 + firstPart = query.match(/\.([^\.]*)$/)[1]; 1.466 + query = query.slice(0, query.length - firstPart.length - 1); 1.467 + } 1.468 + else if (this.state == this.States.ID) { 1.469 + // gets the id that is being completed. For ex. '.foo#b' returns 'b' 1.470 + firstPart = query.match(/#([^#]*)$/)[1]; 1.471 + query = query.slice(0, query.length - firstPart.length - 1); 1.472 + } 1.473 + // TODO: implement some caching so that over the wire request is not made 1.474 + // everytime. 1.475 + if (/[\s+>~]$/.test(query)) { 1.476 + query += "*"; 1.477 + } 1.478 + this._currentSuggesting = query; 1.479 + return this.walker.getSuggestionsForQuery(query, firstPart, this.state).then(result => { 1.480 + if (this._currentSuggesting != result.query) { 1.481 + // This means that this response is for a previous request and the user 1.482 + // as since typed something extra leading to a new request. 1.483 + return; 1.484 + } 1.485 + this._lastToLastValidSearch = this._lastValidSearch; 1.486 + if (this.state == this.States.CLASS) { 1.487 + firstPart = "." + firstPart; 1.488 + } 1.489 + else if (this.state == this.States.ID) { 1.490 + firstPart = "#" + firstPart; 1.491 + } 1.492 + this._showPopup(result.suggestions, firstPart); 1.493 + }); 1.494 + } 1.495 +};