browser/devtools/inspector/selector-search.js

Wed, 31 Dec 2014 06:09:35 +0100

author
Michael Schloh von Bennewitz <michael@schloh.com>
date
Wed, 31 Dec 2014 06:09:35 +0100
changeset 0
6474c204b198
permissions
-rw-r--r--

Cloned upstream origin tor-browser at tor-browser-31.3.0esr-4.5-1-build1
revision ID fc1c9ff7c1b2defdbc039f12214767608f46423f for hacking purpose.

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

mercurial