browser/devtools/inspector/selector-search.js

Thu, 22 Jan 2015 13:21:57 +0100

author
Michael Schloh von Bennewitz <michael@schloh.com>
date
Thu, 22 Jan 2015 13:21:57 +0100
branch
TOR_BUG_9701
changeset 15
b8a032363ba2
permissions
-rw-r--r--

Incorporate requested changes from Mozilla in review:
https://bugzilla.mozilla.org/show_bug.cgi?id=1123480#c6

michael@0 1 /* This Source Code Form is subject to the terms of the Mozilla Public
michael@0 2 * License, v. 2.0. If a copy of the MPL was not distributed with this
michael@0 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
michael@0 4
michael@0 5 "use strict";
michael@0 6
michael@0 7 const promise = require("devtools/toolkit/deprecated-sync-thenables");
michael@0 8
michael@0 9 loader.lazyGetter(this, "AutocompletePopup", () => require("devtools/shared/autocomplete-popup").AutocompletePopup);
michael@0 10
michael@0 11 // Maximum number of selector suggestions shown in the panel.
michael@0 12 const MAX_SUGGESTIONS = 15;
michael@0 13
michael@0 14 /**
michael@0 15 * Converts any input box on a page to a CSS selector search and suggestion box.
michael@0 16 *
michael@0 17 * @constructor
michael@0 18 * @param InspectorPanel aInspector
michael@0 19 * The InspectorPanel whose `walker` attribute should be used for
michael@0 20 * document traversal.
michael@0 21 * @param nsiInputElement aInputNode
michael@0 22 * The input element to which the panel will be attached and from where
michael@0 23 * search input will be taken.
michael@0 24 */
michael@0 25 function SelectorSearch(aInspector, aInputNode) {
michael@0 26 this.inspector = aInspector;
michael@0 27 this.searchBox = aInputNode;
michael@0 28 this.panelDoc = this.searchBox.ownerDocument;
michael@0 29
michael@0 30 // initialize variables.
michael@0 31 this._lastSearched = null;
michael@0 32 this._lastValidSearch = "";
michael@0 33 this._lastToLastValidSearch = null;
michael@0 34 this._searchResults = null;
michael@0 35 this._searchSuggestions = {};
michael@0 36 this._searchIndex = 0;
michael@0 37
michael@0 38 // bind!
michael@0 39 this._showPopup = this._showPopup.bind(this);
michael@0 40 this._onHTMLSearch = this._onHTMLSearch.bind(this);
michael@0 41 this._onSearchKeypress = this._onSearchKeypress.bind(this);
michael@0 42 this._onListBoxKeypress = this._onListBoxKeypress.bind(this);
michael@0 43
michael@0 44 // Options for the AutocompletePopup.
michael@0 45 let options = {
michael@0 46 panelId: "inspector-searchbox-panel",
michael@0 47 listBoxId: "searchbox-panel-listbox",
michael@0 48 autoSelect: true,
michael@0 49 position: "before_start",
michael@0 50 direction: "ltr",
michael@0 51 theme: "auto",
michael@0 52 onClick: this._onListBoxKeypress,
michael@0 53 onKeypress: this._onListBoxKeypress
michael@0 54 };
michael@0 55 this.searchPopup = new AutocompletePopup(this.panelDoc, options);
michael@0 56
michael@0 57 // event listeners.
michael@0 58 this.searchBox.addEventListener("command", this._onHTMLSearch, true);
michael@0 59 this.searchBox.addEventListener("keypress", this._onSearchKeypress, true);
michael@0 60
michael@0 61 // For testing, we need to be able to wait for the most recent node request
michael@0 62 // to finish. Tests can watch this promise for that.
michael@0 63 this._lastQuery = promise.resolve(null);
michael@0 64 }
michael@0 65
michael@0 66 exports.SelectorSearch = SelectorSearch;
michael@0 67
michael@0 68 SelectorSearch.prototype = {
michael@0 69
michael@0 70 get walker() this.inspector.walker,
michael@0 71
michael@0 72 // The possible states of the query.
michael@0 73 States: {
michael@0 74 CLASS: "class",
michael@0 75 ID: "id",
michael@0 76 TAG: "tag",
michael@0 77 },
michael@0 78
michael@0 79 // The current state of the query.
michael@0 80 _state: null,
michael@0 81
michael@0 82 // The query corresponding to last state computation.
michael@0 83 _lastStateCheckAt: null,
michael@0 84
michael@0 85 /**
michael@0 86 * Computes the state of the query. State refers to whether the query
michael@0 87 * currently requires a class suggestion, or a tag, or an Id suggestion.
michael@0 88 * This getter will effectively compute the state by traversing the query
michael@0 89 * character by character each time the query changes.
michael@0 90 *
michael@0 91 * @example
michael@0 92 * '#f' requires an Id suggestion, so the state is States.ID
michael@0 93 * 'div > .foo' requires class suggestion, so state is States.CLASS
michael@0 94 */
michael@0 95 get state() {
michael@0 96 if (!this.searchBox || !this.searchBox.value) {
michael@0 97 return null;
michael@0 98 }
michael@0 99
michael@0 100 let query = this.searchBox.value;
michael@0 101 if (this._lastStateCheckAt == query) {
michael@0 102 // If query is the same, return early.
michael@0 103 return this._state;
michael@0 104 }
michael@0 105 this._lastStateCheckAt = query;
michael@0 106
michael@0 107 this._state = null;
michael@0 108 let subQuery = "";
michael@0 109 // Now we iterate over the query and decide the state character by character.
michael@0 110 // The logic here is that while iterating, the state can go from one to
michael@0 111 // another with some restrictions. Like, if the state is Class, then it can
michael@0 112 // never go to Tag state without a space or '>' character; Or like, a Class
michael@0 113 // state with only '.' cannot go to an Id state without any [a-zA-Z] after
michael@0 114 // the '.' which means that '.#' is a selector matching a class name '#'.
michael@0 115 // Similarily for '#.' which means a selctor matching an id '.'.
michael@0 116 for (let i = 1; i <= query.length; i++) {
michael@0 117 // Calculate the state.
michael@0 118 subQuery = query.slice(0, i);
michael@0 119 let [secondLastChar, lastChar] = subQuery.slice(-2);
michael@0 120 switch (this._state) {
michael@0 121 case null:
michael@0 122 // This will happen only in the first iteration of the for loop.
michael@0 123 lastChar = secondLastChar;
michael@0 124 case this.States.TAG:
michael@0 125 this._state = lastChar == "."
michael@0 126 ? this.States.CLASS
michael@0 127 : lastChar == "#"
michael@0 128 ? this.States.ID
michael@0 129 : this.States.TAG;
michael@0 130 break;
michael@0 131
michael@0 132 case this.States.CLASS:
michael@0 133 if (subQuery.match(/[\.]+[^\.]*$/)[0].length > 2) {
michael@0 134 // Checks whether the subQuery has atleast one [a-zA-Z] after the '.'.
michael@0 135 this._state = (lastChar == " " || lastChar == ">")
michael@0 136 ? this.States.TAG
michael@0 137 : lastChar == "#"
michael@0 138 ? this.States.ID
michael@0 139 : this.States.CLASS;
michael@0 140 }
michael@0 141 break;
michael@0 142
michael@0 143 case this.States.ID:
michael@0 144 if (subQuery.match(/[#]+[^#]*$/)[0].length > 2) {
michael@0 145 // Checks whether the subQuery has atleast one [a-zA-Z] after the '#'.
michael@0 146 this._state = (lastChar == " " || lastChar == ">")
michael@0 147 ? this.States.TAG
michael@0 148 : lastChar == "."
michael@0 149 ? this.States.CLASS
michael@0 150 : this.States.ID;
michael@0 151 }
michael@0 152 break;
michael@0 153 }
michael@0 154 }
michael@0 155 return this._state;
michael@0 156 },
michael@0 157
michael@0 158 /**
michael@0 159 * Removes event listeners and cleans up references.
michael@0 160 */
michael@0 161 destroy: function() {
michael@0 162 // event listeners.
michael@0 163 this.searchBox.removeEventListener("command", this._onHTMLSearch, true);
michael@0 164 this.searchBox.removeEventListener("keypress", this._onSearchKeypress, true);
michael@0 165 this.searchPopup.destroy();
michael@0 166 this.searchPopup = null;
michael@0 167 this.searchBox = null;
michael@0 168 this.panelDoc = null;
michael@0 169 this._searchResults = null;
michael@0 170 this._searchSuggestions = null;
michael@0 171 },
michael@0 172
michael@0 173 _selectResult: function(index) {
michael@0 174 return this._searchResults.item(index).then(node => {
michael@0 175 this.inspector.selection.setNodeFront(node, "selectorsearch");
michael@0 176 });
michael@0 177 },
michael@0 178
michael@0 179 /**
michael@0 180 * The command callback for the input box. This function is automatically
michael@0 181 * invoked as the user is typing if the input box type is search.
michael@0 182 */
michael@0 183 _onHTMLSearch: function() {
michael@0 184 let query = this.searchBox.value;
michael@0 185 if (query == this._lastSearched) {
michael@0 186 return;
michael@0 187 }
michael@0 188 this._lastSearched = query;
michael@0 189 this._searchResults = [];
michael@0 190 this._searchIndex = 0;
michael@0 191
michael@0 192 if (query.length == 0) {
michael@0 193 this._lastValidSearch = "";
michael@0 194 this.searchBox.removeAttribute("filled");
michael@0 195 this.searchBox.classList.remove("devtools-no-search-result");
michael@0 196 if (this.searchPopup.isOpen) {
michael@0 197 this.searchPopup.hidePopup();
michael@0 198 }
michael@0 199 return;
michael@0 200 }
michael@0 201
michael@0 202 this.searchBox.setAttribute("filled", true);
michael@0 203 let queryList = null;
michael@0 204
michael@0 205 this._lastQuery = this.walker.querySelectorAll(this.walker.rootNode, query).then(list => {
michael@0 206 return list;
michael@0 207 }, (err) => {
michael@0 208 // Failures are ok here, just use a null item list;
michael@0 209 return null;
michael@0 210 }).then(queryList => {
michael@0 211 // Value has changed since we started this request, we're done.
michael@0 212 if (query != this.searchBox.value) {
michael@0 213 if (queryList) {
michael@0 214 queryList.release();
michael@0 215 }
michael@0 216 return promise.reject(null);
michael@0 217 }
michael@0 218
michael@0 219 this._searchResults = queryList || [];
michael@0 220 if (this._searchResults && this._searchResults.length > 0) {
michael@0 221 this._lastValidSearch = query;
michael@0 222 // Even though the selector matched atleast one node, there is still
michael@0 223 // possibility of suggestions.
michael@0 224 if (query.match(/[\s>+]$/)) {
michael@0 225 // If the query has a space or '>' at the end, create a selector to match
michael@0 226 // the children of the selector inside the search box by adding a '*'.
michael@0 227 this._lastValidSearch += "*";
michael@0 228 }
michael@0 229 else if (query.match(/[\s>+][\.#a-zA-Z][\.#>\s+]*$/)) {
michael@0 230 // If the query is a partial descendant selector which does not matches
michael@0 231 // any node, remove the last incomplete part and add a '*' to match
michael@0 232 // everything. For ex, convert 'foo > b' to 'foo > *' .
michael@0 233 let lastPart = query.match(/[\s>+][\.#a-zA-Z][^>\s+]*$/)[0];
michael@0 234 this._lastValidSearch = query.slice(0, -1 * lastPart.length + 1) + "*";
michael@0 235 }
michael@0 236
michael@0 237 if (!query.slice(-1).match(/[\.#\s>+]/)) {
michael@0 238 // Hide the popup if we have some matching nodes and the query is not
michael@0 239 // ending with [.# >] which means that the selector is not at the
michael@0 240 // beginning of a new class, tag or id.
michael@0 241 if (this.searchPopup.isOpen) {
michael@0 242 this.searchPopup.hidePopup();
michael@0 243 }
michael@0 244 this.searchBox.classList.remove("devtools-no-search-result");
michael@0 245
michael@0 246 return this._selectResult(0);
michael@0 247 }
michael@0 248 return this._selectResult(0).then(() => {
michael@0 249 this.searchBox.classList.remove("devtools-no-search-result");
michael@0 250 }).then(() => this.showSuggestions());
michael@0 251 }
michael@0 252 if (query.match(/[\s>+]$/)) {
michael@0 253 this._lastValidSearch = query + "*";
michael@0 254 }
michael@0 255 else if (query.match(/[\s>+][\.#a-zA-Z][\.#>\s+]*$/)) {
michael@0 256 let lastPart = query.match(/[\s+>][\.#a-zA-Z][^>\s+]*$/)[0];
michael@0 257 this._lastValidSearch = query.slice(0, -1 * lastPart.length + 1) + "*";
michael@0 258 }
michael@0 259 this.searchBox.classList.add("devtools-no-search-result");
michael@0 260 return this.showSuggestions();
michael@0 261 });
michael@0 262 },
michael@0 263
michael@0 264 /**
michael@0 265 * Handles keypresses inside the input box.
michael@0 266 */
michael@0 267 _onSearchKeypress: function(aEvent) {
michael@0 268 let query = this.searchBox.value;
michael@0 269 switch(aEvent.keyCode) {
michael@0 270 case aEvent.DOM_VK_RETURN:
michael@0 271 if (query == this._lastSearched && this._searchResults) {
michael@0 272 this._searchIndex = (this._searchIndex + 1) % this._searchResults.length;
michael@0 273 }
michael@0 274 else {
michael@0 275 this._onHTMLSearch();
michael@0 276 return;
michael@0 277 }
michael@0 278 break;
michael@0 279
michael@0 280 case aEvent.DOM_VK_UP:
michael@0 281 if (this.searchPopup.isOpen && this.searchPopup.itemCount > 0) {
michael@0 282 this.searchPopup.focus();
michael@0 283 if (this.searchPopup.selectedIndex == this.searchPopup.itemCount - 1) {
michael@0 284 this.searchPopup.selectedIndex =
michael@0 285 Math.max(0, this.searchPopup.itemCount - 2);
michael@0 286 }
michael@0 287 else {
michael@0 288 this.searchPopup.selectedIndex = this.searchPopup.itemCount - 1;
michael@0 289 }
michael@0 290 this.searchBox.value = this.searchPopup.selectedItem.label;
michael@0 291 }
michael@0 292 else if (--this._searchIndex < 0) {
michael@0 293 this._searchIndex = this._searchResults.length - 1;
michael@0 294 }
michael@0 295 break;
michael@0 296
michael@0 297 case aEvent.DOM_VK_DOWN:
michael@0 298 if (this.searchPopup.isOpen && this.searchPopup.itemCount > 0) {
michael@0 299 this.searchPopup.focus();
michael@0 300 this.searchPopup.selectedIndex = 0;
michael@0 301 this.searchBox.value = this.searchPopup.selectedItem.label;
michael@0 302 }
michael@0 303 this._searchIndex = (this._searchIndex + 1) % this._searchResults.length;
michael@0 304 break;
michael@0 305
michael@0 306 case aEvent.DOM_VK_TAB:
michael@0 307 if (this.searchPopup.isOpen &&
michael@0 308 this.searchPopup.getItemAtIndex(this.searchPopup.itemCount - 1)
michael@0 309 .preLabel == query) {
michael@0 310 this.searchPopup.selectedIndex = this.searchPopup.itemCount - 1;
michael@0 311 this.searchBox.value = this.searchPopup.selectedItem.label;
michael@0 312 this._onHTMLSearch();
michael@0 313 }
michael@0 314 break;
michael@0 315
michael@0 316 case aEvent.DOM_VK_BACK_SPACE:
michael@0 317 case aEvent.DOM_VK_DELETE:
michael@0 318 // need to throw away the lastValidSearch.
michael@0 319 this._lastToLastValidSearch = null;
michael@0 320 // This gets the most complete selector from the query. For ex.
michael@0 321 // '.foo.ba' returns '.foo' , '#foo > .bar.baz' returns '#foo > .bar'
michael@0 322 // '.foo +bar' returns '.foo +' and likewise.
michael@0 323 this._lastValidSearch = (query.match(/(.*)[\.#][^\.# ]{0,}$/) ||
michael@0 324 query.match(/(.*[\s>+])[a-zA-Z][^\.# ]{0,}$/) ||
michael@0 325 ["",""])[1];
michael@0 326 return;
michael@0 327
michael@0 328 default:
michael@0 329 return;
michael@0 330 }
michael@0 331
michael@0 332 aEvent.preventDefault();
michael@0 333 aEvent.stopPropagation();
michael@0 334 if (this._searchResults && this._searchResults.length > 0) {
michael@0 335 this._lastQuery = this._selectResult(this._searchIndex);
michael@0 336 }
michael@0 337 },
michael@0 338
michael@0 339 /**
michael@0 340 * Handles keypress and mouse click on the suggestions richlistbox.
michael@0 341 */
michael@0 342 _onListBoxKeypress: function(aEvent) {
michael@0 343 switch(aEvent.keyCode || aEvent.button) {
michael@0 344 case aEvent.DOM_VK_RETURN:
michael@0 345 case aEvent.DOM_VK_TAB:
michael@0 346 case 0: // left mouse button
michael@0 347 aEvent.stopPropagation();
michael@0 348 aEvent.preventDefault();
michael@0 349 this.searchBox.value = this.searchPopup.selectedItem.label;
michael@0 350 this.searchBox.focus();
michael@0 351 this._onHTMLSearch();
michael@0 352 break;
michael@0 353
michael@0 354 case aEvent.DOM_VK_UP:
michael@0 355 if (this.searchPopup.selectedIndex == 0) {
michael@0 356 this.searchPopup.selectedIndex = -1;
michael@0 357 aEvent.stopPropagation();
michael@0 358 aEvent.preventDefault();
michael@0 359 this.searchBox.focus();
michael@0 360 }
michael@0 361 else {
michael@0 362 let index = this.searchPopup.selectedIndex;
michael@0 363 this.searchBox.value = this.searchPopup.getItemAtIndex(index - 1).label;
michael@0 364 }
michael@0 365 break;
michael@0 366
michael@0 367 case aEvent.DOM_VK_DOWN:
michael@0 368 if (this.searchPopup.selectedIndex == this.searchPopup.itemCount - 1) {
michael@0 369 this.searchPopup.selectedIndex = -1;
michael@0 370 aEvent.stopPropagation();
michael@0 371 aEvent.preventDefault();
michael@0 372 this.searchBox.focus();
michael@0 373 }
michael@0 374 else {
michael@0 375 let index = this.searchPopup.selectedIndex;
michael@0 376 this.searchBox.value = this.searchPopup.getItemAtIndex(index + 1).label;
michael@0 377 }
michael@0 378 break;
michael@0 379
michael@0 380 case aEvent.DOM_VK_BACK_SPACE:
michael@0 381 aEvent.stopPropagation();
michael@0 382 aEvent.preventDefault();
michael@0 383 this.searchBox.focus();
michael@0 384 if (this.searchBox.selectionStart > 0) {
michael@0 385 this.searchBox.value =
michael@0 386 this.searchBox.value.substring(0, this.searchBox.selectionStart - 1);
michael@0 387 }
michael@0 388 this._lastToLastValidSearch = null;
michael@0 389 let query = this.searchBox.value;
michael@0 390 this._lastValidSearch = (query.match(/(.*)[\.#][^\.# ]{0,}$/) ||
michael@0 391 query.match(/(.*[\s>+])[a-zA-Z][^\.# ]{0,}$/) ||
michael@0 392 ["",""])[1];
michael@0 393 this._onHTMLSearch();
michael@0 394 break;
michael@0 395 }
michael@0 396 },
michael@0 397
michael@0 398 /**
michael@0 399 * Populates the suggestions list and show the suggestion popup.
michael@0 400 */
michael@0 401 _showPopup: function(aList, aFirstPart) {
michael@0 402 let total = 0;
michael@0 403 let query = this.searchBox.value;
michael@0 404 let toLowerCase = false;
michael@0 405 let items = [];
michael@0 406 // In case of tagNames, change the case to small.
michael@0 407 if (query.match(/.*[\.#][^\.#]{0,}$/) == null) {
michael@0 408 toLowerCase = true;
michael@0 409 }
michael@0 410 for (let [value, count] of aList) {
michael@0 411 // for cases like 'div ' or 'div >' or 'div+'
michael@0 412 if (query.match(/[\s>+]$/)) {
michael@0 413 value = query + value;
michael@0 414 }
michael@0 415 // for cases like 'div #a' or 'div .a' or 'div > d' and likewise
michael@0 416 else if (query.match(/[\s>+][\.#a-zA-Z][^\s>+\.#]*$/)) {
michael@0 417 let lastPart = query.match(/[\s>+][\.#a-zA-Z][^>\s+\.#]*$/)[0];
michael@0 418 value = query.slice(0, -1 * lastPart.length + 1) + value;
michael@0 419 }
michael@0 420 // for cases like 'div.class' or '#foo.bar' and likewise
michael@0 421 else if (query.match(/[a-zA-Z][#\.][^#\.\s+>]*$/)) {
michael@0 422 let lastPart = query.match(/[a-zA-Z][#\.][^#\.\s>+]*$/)[0];
michael@0 423 value = query.slice(0, -1 * lastPart.length + 1) + value;
michael@0 424 }
michael@0 425 let item = {
michael@0 426 preLabel: query,
michael@0 427 label: value,
michael@0 428 count: count
michael@0 429 };
michael@0 430 if (toLowerCase) {
michael@0 431 item.label = value.toLowerCase();
michael@0 432 }
michael@0 433 items.unshift(item);
michael@0 434 if (++total > MAX_SUGGESTIONS - 1) {
michael@0 435 break;
michael@0 436 }
michael@0 437 }
michael@0 438 if (total > 0) {
michael@0 439 this.searchPopup.setItems(items);
michael@0 440 this.searchPopup.openPopup(this.searchBox);
michael@0 441 }
michael@0 442 else {
michael@0 443 this.searchPopup.hidePopup();
michael@0 444 }
michael@0 445 },
michael@0 446
michael@0 447 /**
michael@0 448 * Suggests classes,ids and tags based on the user input as user types in the
michael@0 449 * searchbox.
michael@0 450 */
michael@0 451 showSuggestions: function() {
michael@0 452 let query = this.searchBox.value;
michael@0 453 let firstPart = "";
michael@0 454 if (this.state == this.States.TAG) {
michael@0 455 // gets the tag that is being completed. For ex. 'div.foo > s' returns 's',
michael@0 456 // 'di' returns 'di' and likewise.
michael@0 457 firstPart = (query.match(/[\s>+]?([a-zA-Z]*)$/) || ["", query])[1];
michael@0 458 query = query.slice(0, query.length - firstPart.length);
michael@0 459 }
michael@0 460 else if (this.state == this.States.CLASS) {
michael@0 461 // gets the class that is being completed. For ex. '.foo.b' returns 'b'
michael@0 462 firstPart = query.match(/\.([^\.]*)$/)[1];
michael@0 463 query = query.slice(0, query.length - firstPart.length - 1);
michael@0 464 }
michael@0 465 else if (this.state == this.States.ID) {
michael@0 466 // gets the id that is being completed. For ex. '.foo#b' returns 'b'
michael@0 467 firstPart = query.match(/#([^#]*)$/)[1];
michael@0 468 query = query.slice(0, query.length - firstPart.length - 1);
michael@0 469 }
michael@0 470 // TODO: implement some caching so that over the wire request is not made
michael@0 471 // everytime.
michael@0 472 if (/[\s+>~]$/.test(query)) {
michael@0 473 query += "*";
michael@0 474 }
michael@0 475 this._currentSuggesting = query;
michael@0 476 return this.walker.getSuggestionsForQuery(query, firstPart, this.state).then(result => {
michael@0 477 if (this._currentSuggesting != result.query) {
michael@0 478 // This means that this response is for a previous request and the user
michael@0 479 // as since typed something extra leading to a new request.
michael@0 480 return;
michael@0 481 }
michael@0 482 this._lastToLastValidSearch = this._lastValidSearch;
michael@0 483 if (this.state == this.States.CLASS) {
michael@0 484 firstPart = "." + firstPart;
michael@0 485 }
michael@0 486 else if (this.state == this.States.ID) {
michael@0 487 firstPart = "#" + firstPart;
michael@0 488 }
michael@0 489 this._showPopup(result.suggestions, firstPart);
michael@0 490 });
michael@0 491 }
michael@0 492 };

mercurial