browser/devtools/inspector/selector-search.js

branch
TOR_BUG_9701
changeset 15
b8a032363ba2
equal deleted inserted replaced
-1:000000000000 0:4b0aebdb731b
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/. */
4
5 "use strict";
6
7 const promise = require("devtools/toolkit/deprecated-sync-thenables");
8
9 loader.lazyGetter(this, "AutocompletePopup", () => require("devtools/shared/autocomplete-popup").AutocompletePopup);
10
11 // Maximum number of selector suggestions shown in the panel.
12 const MAX_SUGGESTIONS = 15;
13
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;
29
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;
37
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);
43
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);
56
57 // event listeners.
58 this.searchBox.addEventListener("command", this._onHTMLSearch, true);
59 this.searchBox.addEventListener("keypress", this._onSearchKeypress, true);
60
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 }
65
66 exports.SelectorSearch = SelectorSearch;
67
68 SelectorSearch.prototype = {
69
70 get walker() this.inspector.walker,
71
72 // The possible states of the query.
73 States: {
74 CLASS: "class",
75 ID: "id",
76 TAG: "tag",
77 },
78
79 // The current state of the query.
80 _state: null,
81
82 // The query corresponding to last state computation.
83 _lastStateCheckAt: null,
84
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 }
99
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;
106
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;
131
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;
142
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 },
157
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 },
172
173 _selectResult: function(index) {
174 return this._searchResults.item(index).then(node => {
175 this.inspector.selection.setNodeFront(node, "selectorsearch");
176 });
177 },
178
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;
191
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 }
201
202 this.searchBox.setAttribute("filled", true);
203 let queryList = null;
204
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 }
218
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 }
236
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");
245
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 },
263
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;
279
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;
296
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;
305
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;
315
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;
327
328 default:
329 return;
330 }
331
332 aEvent.preventDefault();
333 aEvent.stopPropagation();
334 if (this._searchResults && this._searchResults.length > 0) {
335 this._lastQuery = this._selectResult(this._searchIndex);
336 }
337 },
338
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;
353
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;
366
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;
379
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 },
397
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 },
446
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