Bug 1890689 accumulate input in LargerReceiverBlockSizeThanDesiredBuffering GTest...
[gecko.git] / devtools / client / inspector / inspector-search.js
blobcac216c06e63e98e44b3f4e8af3f2052bbf3a763
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 { KeyCodes } = require("resource://devtools/client/shared/keycodes.js");
9 const EventEmitter = require("resource://devtools/shared/event-emitter.js");
10 const AutocompletePopup = require("resource://devtools/client/shared/autocomplete-popup.js");
12 // Maximum number of selector suggestions shown in the panel.
13 const MAX_SUGGESTIONS = 15;
15 /**
16  * Converts any input field into a document search box.
17  *
18  * @param {InspectorPanel} inspector
19  *        The InspectorPanel to access the inspector commands for
20  *        search and document traversal.
21  * @param {DOMNode} input
22  *        The input element to which the panel will be attached and from where
23  *        search input will be taken.
24  * @param {DOMNode} clearBtn
25  *        The clear button in the input field that will clear the input value.
26  *
27  * Emits the following events:
28  * - search-cleared: when the search box is emptied
29  * - search-result: when a search is made and a result is selected
30  */
31 function InspectorSearch(inspector, input, clearBtn) {
32   this.inspector = inspector;
33   this.searchBox = input;
34   this.searchClearButton = clearBtn;
35   this._lastSearched = null;
37   this._onKeyDown = this._onKeyDown.bind(this);
38   this._onInput = this._onInput.bind(this);
39   this._onClearSearch = this._onClearSearch.bind(this);
41   this.searchBox.addEventListener("keydown", this._onKeyDown, true);
42   this.searchBox.addEventListener("input", this._onInput, true);
43   this.searchClearButton.addEventListener("click", this._onClearSearch);
45   this.autocompleter = new SelectorAutocompleter(inspector, input);
46   EventEmitter.decorate(this);
49 exports.InspectorSearch = InspectorSearch;
51 InspectorSearch.prototype = {
52   destroy() {
53     this.searchBox.removeEventListener("keydown", this._onKeyDown, true);
54     this.searchBox.removeEventListener("input", this._onInput, true);
55     this.searchClearButton.removeEventListener("click", this._onClearSearch);
56     this.searchBox = null;
57     this.searchClearButton = null;
58     this.autocompleter.destroy();
59   },
61   _onSearch(reverse = false) {
62     this.doFullTextSearch(this.searchBox.value, reverse).catch(console.error);
63   },
65   async doFullTextSearch(query, reverse) {
66     const lastSearched = this._lastSearched;
67     this._lastSearched = query;
69     const searchContainer = this.searchBox.parentNode;
71     if (query.length === 0) {
72       searchContainer.classList.remove("devtools-searchbox-no-match");
73       if (!lastSearched || lastSearched.length) {
74         this.emit("search-cleared");
75       }
76       return;
77     }
79     const res = await this.inspector.commands.inspectorCommand.findNextNode(
80       query,
81       {
82         reverse,
83       }
84     );
86     // Value has changed since we started this request, we're done.
87     if (query !== this.searchBox.value) {
88       return;
89     }
91     if (res) {
92       this.inspector.selection.setNodeFront(res.node, {
93         reason: "inspectorsearch",
94       });
95       searchContainer.classList.remove("devtools-searchbox-no-match");
96       res.query = query;
97       this.emit("search-result", res);
98     } else {
99       searchContainer.classList.add("devtools-searchbox-no-match");
100       this.emit("search-result");
101     }
102   },
104   _onInput() {
105     if (this.searchBox.value.length === 0) {
106       this.searchClearButton.hidden = true;
107       this._onSearch();
108     } else {
109       this.searchClearButton.hidden = false;
110     }
111   },
113   _onKeyDown(event) {
114     if (event.keyCode === KeyCodes.DOM_VK_RETURN) {
115       this._onSearch(event.shiftKey);
116     }
118     const modifierKey =
119       Services.appinfo.OS === "Darwin" ? event.metaKey : event.ctrlKey;
120     if (event.keyCode === KeyCodes.DOM_VK_G && modifierKey) {
121       this._onSearch(event.shiftKey);
122       event.preventDefault();
123     }
124   },
126   _onClearSearch() {
127     this.searchBox.parentNode.classList.remove("devtools-searchbox-no-match");
128     this.searchBox.value = "";
129     this.searchClearButton.hidden = true;
130     this.emit("search-cleared");
131   },
135  * Converts any input box on a page to a CSS selector search and suggestion box.
137  * Emits 'processing-done' event when it is done processing the current
138  * keypress, search request or selection from the list, whether that led to a
139  * search or not.
141  * @constructor
142  * @param InspectorPanel inspector
143  *        The InspectorPanel to access the inspector commands for
144  *        search and document traversal.
145  * @param nsiInputElement inputNode
146  *        The input element to which the panel will be attached and from where
147  *        search input will be taken.
148  */
149 function SelectorAutocompleter(inspector, inputNode) {
150   this.inspector = inspector;
151   this.searchBox = inputNode;
152   this.panelDoc = this.searchBox.ownerDocument;
154   this.showSuggestions = this.showSuggestions.bind(this);
155   this._onSearchKeypress = this._onSearchKeypress.bind(this);
156   this._onSearchPopupClick = this._onSearchPopupClick.bind(this);
157   this._onMarkupMutation = this._onMarkupMutation.bind(this);
159   // Options for the AutocompletePopup.
160   const options = {
161     listId: "searchbox-panel-listbox",
162     autoSelect: true,
163     position: "top",
164     onClick: this._onSearchPopupClick,
165   };
167   // The popup will be attached to the toolbox document.
168   this.searchPopup = new AutocompletePopup(inspector._toolbox.doc, options);
170   this.searchBox.addEventListener("input", this.showSuggestions, true);
171   this.searchBox.addEventListener("keypress", this._onSearchKeypress, true);
172   this.inspector.on("markupmutation", this._onMarkupMutation);
174   EventEmitter.decorate(this);
177 exports.SelectorAutocompleter = SelectorAutocompleter;
179 SelectorAutocompleter.prototype = {
180   get walker() {
181     return this.inspector.walker;
182   },
184   // The possible states of the query.
185   States: {
186     CLASS: "class",
187     ID: "id",
188     TAG: "tag",
189     ATTRIBUTE: "attribute",
190   },
192   // The current state of the query.
193   _state: null,
195   // The query corresponding to last state computation.
196   _lastStateCheckAt: null,
198   /**
199    * Computes the state of the query. State refers to whether the query
200    * currently requires a class suggestion, or a tag, or an Id suggestion.
201    * This getter will effectively compute the state by traversing the query
202    * character by character each time the query changes.
203    *
204    * @example
205    *        '#f' requires an Id suggestion, so the state is States.ID
206    *        'div > .foo' requires class suggestion, so state is States.CLASS
207    */
208   // eslint-disable-next-line complexity
209   get state() {
210     if (!this.searchBox || !this.searchBox.value) {
211       return null;
212     }
214     const query = this.searchBox.value;
215     if (this._lastStateCheckAt == query) {
216       // If query is the same, return early.
217       return this._state;
218     }
219     this._lastStateCheckAt = query;
221     this._state = null;
222     let subQuery = "";
223     // Now we iterate over the query and decide the state character by
224     // character.
225     // The logic here is that while iterating, the state can go from one to
226     // another with some restrictions. Like, if the state is Class, then it can
227     // never go to Tag state without a space or '>' character; Or like, a Class
228     // state with only '.' cannot go to an Id state without any [a-zA-Z] after
229     // the '.' which means that '.#' is a selector matching a class name '#'.
230     // Similarily for '#.' which means a selctor matching an id '.'.
231     for (let i = 1; i <= query.length; i++) {
232       // Calculate the state.
233       subQuery = query.slice(0, i);
234       let [secondLastChar, lastChar] = subQuery.slice(-2);
235       switch (this._state) {
236         case null:
237           // This will happen only in the first iteration of the for loop.
238           lastChar = secondLastChar;
240         case this.States.TAG: // eslint-disable-line
241           if (lastChar === ".") {
242             this._state = this.States.CLASS;
243           } else if (lastChar === "#") {
244             this._state = this.States.ID;
245           } else if (lastChar === "[") {
246             this._state = this.States.ATTRIBUTE;
247           } else {
248             this._state = this.States.TAG;
249           }
250           break;
252         case this.States.CLASS:
253           if (subQuery.match(/[\.]+[^\.]*$/)[0].length > 2) {
254             // Checks whether the subQuery has atleast one [a-zA-Z] after the
255             // '.'.
256             if (lastChar === " " || lastChar === ">") {
257               this._state = this.States.TAG;
258             } else if (lastChar === "#") {
259               this._state = this.States.ID;
260             } else if (lastChar === "[") {
261               this._state = this.States.ATTRIBUTE;
262             } else {
263               this._state = this.States.CLASS;
264             }
265           }
266           break;
268         case this.States.ID:
269           if (subQuery.match(/[#]+[^#]*$/)[0].length > 2) {
270             // Checks whether the subQuery has atleast one [a-zA-Z] after the
271             // '#'.
272             if (lastChar === " " || lastChar === ">") {
273               this._state = this.States.TAG;
274             } else if (lastChar === ".") {
275               this._state = this.States.CLASS;
276             } else if (lastChar === "[") {
277               this._state = this.States.ATTRIBUTE;
278             } else {
279               this._state = this.States.ID;
280             }
281           }
282           break;
284         case this.States.ATTRIBUTE:
285           if (subQuery.match(/[\[][^\]]+[\]]/) !== null) {
286             // Checks whether the subQuery has at least one ']' after the '['.
287             if (lastChar === " " || lastChar === ">") {
288               this._state = this.States.TAG;
289             } else if (lastChar === ".") {
290               this._state = this.States.CLASS;
291             } else if (lastChar === "#") {
292               this._state = this.States.ID;
293             } else {
294               this._state = this.States.ATTRIBUTE;
295             }
296           }
297           break;
298       }
299     }
300     return this._state;
301   },
303   /**
304    * Removes event listeners and cleans up references.
305    */
306   destroy() {
307     this.searchBox.removeEventListener("input", this.showSuggestions, true);
308     this.searchBox.removeEventListener(
309       "keypress",
310       this._onSearchKeypress,
311       true
312     );
313     this.inspector.off("markupmutation", this._onMarkupMutation);
314     this.searchPopup.destroy();
315     this.searchPopup = null;
316     this.searchBox = null;
317     this.panelDoc = null;
318   },
320   /**
321    * Handles keypresses inside the input box.
322    */
323   _onSearchKeypress(event) {
324     const popup = this.searchPopup;
325     switch (event.keyCode) {
326       case KeyCodes.DOM_VK_RETURN:
327       case KeyCodes.DOM_VK_TAB:
328         if (popup.isOpen) {
329           if (popup.selectedItem) {
330             this.searchBox.value = popup.selectedItem.label;
331           }
332           this.hidePopup();
333         } else if (!popup.isOpen) {
334           // When tab is pressed with focus on searchbox and closed popup,
335           // do not prevent the default to avoid a keyboard trap and move focus
336           // to next/previous element.
337           this.emitForTests("processing-done");
338           return;
339         }
340         break;
342       case KeyCodes.DOM_VK_UP:
343         if (popup.isOpen && popup.itemCount > 0) {
344           popup.selectPreviousItem();
345           this.searchBox.value = popup.selectedItem.label;
346         }
347         break;
349       case KeyCodes.DOM_VK_DOWN:
350         if (popup.isOpen && popup.itemCount > 0) {
351           popup.selectNextItem();
352           this.searchBox.value = popup.selectedItem.label;
353         }
354         break;
356       case KeyCodes.DOM_VK_ESCAPE:
357         if (popup.isOpen) {
358           this.hidePopup();
359         } else {
360           this.emitForTests("processing-done");
361           return;
362         }
363         break;
365       default:
366         return;
367     }
369     event.preventDefault();
370     event.stopPropagation();
371     this.emitForTests("processing-done");
372   },
374   /**
375    * Handles click events from the autocomplete popup.
376    */
377   _onSearchPopupClick(event) {
378     const selectedItem = this.searchPopup.selectedItem;
379     if (selectedItem) {
380       this.searchBox.value = selectedItem.label;
381     }
382     this.hidePopup();
384     event.preventDefault();
385     event.stopPropagation();
386   },
388   /**
389    * Reset previous search results on markup-mutations to make sure we search
390    * again after nodes have been added/removed/changed.
391    */
392   _onMarkupMutation() {
393     this._searchResults = null;
394     this._lastSearched = null;
395   },
397   /**
398    * Populates the suggestions list and show the suggestion popup.
399    *
400    * @return {Promise} promise that will resolve when the autocomplete popup is fully
401    * displayed or hidden.
402    */
403   _showPopup(list, popupState) {
404     let total = 0;
405     const query = this.searchBox.value;
406     const items = [];
408     for (let [value, , state] of list) {
409       if (query.match(/[\s>+]$/)) {
410         // for cases like 'div ' or 'div >' or 'div+'
411         value = query + value;
412       } else if (query.match(/[\s>+][\.#a-zA-Z][^\s>+\.#\[]*$/)) {
413         // for cases like 'div #a' or 'div .a' or 'div > d' and likewise
414         const lastPart = query.match(/[\s>+][\.#a-zA-Z][^\s>+\.#\[]*$/)[0];
415         value = query.slice(0, -1 * lastPart.length + 1) + value;
416       } else if (query.match(/[a-zA-Z][#\.][^#\.\s+>]*$/)) {
417         // for cases like 'div.class' or '#foo.bar' and likewise
418         const lastPart = query.match(/[a-zA-Z][#\.][^#\.\s+>]*$/)[0];
419         value = query.slice(0, -1 * lastPart.length + 1) + value;
420       } else if (query.match(/[a-zA-Z]*\[[^\]]*\][^\]]*/)) {
421         // for cases like '[foo].bar' and likewise
422         const attrPart = query.substring(0, query.lastIndexOf("]") + 1);
423         value = attrPart + value;
424       }
426       const item = {
427         preLabel: query,
428         label: value,
429       };
431       // In case the query's state is tag and the item's state is id or class
432       // adjust the preLabel
433       if (popupState === this.States.TAG && state === this.States.CLASS) {
434         item.preLabel = "." + item.preLabel;
435       }
436       if (popupState === this.States.TAG && state === this.States.ID) {
437         item.preLabel = "#" + item.preLabel;
438       }
440       items.push(item);
441       if (++total > MAX_SUGGESTIONS - 1) {
442         break;
443       }
444     }
446     if (total > 0) {
447       const onPopupOpened = this.searchPopup.once("popup-opened");
448       this.searchPopup.once("popup-closed", () => {
449         this.searchPopup.setItems(items);
450         // The offset is left padding (22px) + left border width (1px) of searchBox.
451         const xOffset = 23;
452         this.searchPopup.openPopup(this.searchBox, xOffset);
453       });
454       this.searchPopup.hidePopup();
455       return onPopupOpened;
456     }
458     return this.hidePopup();
459   },
461   /**
462    * Hide the suggestion popup if necessary.
463    */
464   hidePopup() {
465     const onPopupClosed = this.searchPopup.once("popup-closed");
466     this.searchPopup.hidePopup();
467     return onPopupClosed;
468   },
470   /**
471    * Suggests classes,ids and tags based on the user input as user types in the
472    * searchbox.
473    */
474   async showSuggestions() {
475     let query = this.searchBox.value;
476     const originalQuery = this.searchBox.value;
478     const state = this.state;
479     let firstPart = "";
481     if (query.endsWith("*") || state === this.States.ATTRIBUTE) {
482       // Hide the popup if the query ends with * (because we don't want to
483       // suggest all nodes) or if it is an attribute selector (because
484       // it would give a lot of useless results).
485       this.hidePopup();
486       this.emitForTests("processing-done", { query: originalQuery });
487       return;
488     }
490     if (state === this.States.TAG) {
491       // gets the tag that is being completed. For ex. 'div.foo > s' returns
492       // 's', 'di' returns 'di' and likewise.
493       firstPart = (query.match(/[\s>+]?([a-zA-Z]*)$/) || ["", query])[1];
494       query = query.slice(0, query.length - firstPart.length);
495     } else if (state === this.States.CLASS) {
496       // gets the class that is being completed. For ex. '.foo.b' returns 'b'
497       firstPart = query.match(/\.([^\.]*)$/)[1];
498       query = query.slice(0, query.length - firstPart.length - 1);
499     } else if (state === this.States.ID) {
500       // gets the id that is being completed. For ex. '.foo#b' returns 'b'
501       firstPart = query.match(/#([^#]*)$/)[1];
502       query = query.slice(0, query.length - firstPart.length - 1);
503     }
504     // TODO: implement some caching so that over the wire request is not made
505     // everytime.
506     if (/[\s+>~]$/.test(query)) {
507       query += "*";
508     }
510     let suggestions =
511       await this.inspector.commands.inspectorCommand.getSuggestionsForQuery(
512         query,
513         firstPart,
514         state
515       );
517     if (state === this.States.CLASS) {
518       firstPart = "." + firstPart;
519     } else if (state === this.States.ID) {
520       firstPart = "#" + firstPart;
521     }
523     // If there is a single tag match and it's what the user typed, then
524     // don't need to show a popup.
525     if (suggestions.length === 1 && suggestions[0][0] === firstPart) {
526       suggestions = [];
527     }
529     // Wait for the autocomplete-popup to fire its popup-opened event, to make sure
530     // the autoSelect item has been selected.
531     await this._showPopup(suggestions, state);
532     this.emitForTests("processing-done", { query: originalQuery });
533   },