|
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 }; |