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/. */
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;
16 * Converts any input field into a document search box.
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.
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
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 = {
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();
61 _onSearch(reverse = false) {
62 this.doFullTextSearch(this.searchBox.value, reverse).catch(console.error);
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");
79 const res = await this.inspector.commands.inspectorCommand.findNextNode(
86 // Value has changed since we started this request, we're done.
87 if (query !== this.searchBox.value) {
92 this.inspector.selection.setNodeFront(res.node, {
93 reason: "inspectorsearch",
95 searchContainer.classList.remove("devtools-searchbox-no-match");
97 this.emit("search-result", res);
99 searchContainer.classList.add("devtools-searchbox-no-match");
100 this.emit("search-result");
105 if (this.searchBox.value.length === 0) {
106 this.searchClearButton.hidden = true;
109 this.searchClearButton.hidden = false;
114 if (event.keyCode === KeyCodes.DOM_VK_RETURN) {
115 this._onSearch(event.shiftKey);
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();
127 this.searchBox.parentNode.classList.remove("devtools-searchbox-no-match");
128 this.searchBox.value = "";
129 this.searchClearButton.hidden = true;
130 this.emit("search-cleared");
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
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.
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.
161 listId: "searchbox-panel-listbox",
164 onClick: this._onSearchPopupClick,
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 = {
181 return this.inspector.walker;
184 // The possible states of the query.
189 ATTRIBUTE: "attribute",
192 // The current state of the query.
195 // The query corresponding to last state computation.
196 _lastStateCheckAt: null,
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.
205 * '#f' requires an Id suggestion, so the state is States.ID
206 * 'div > .foo' requires class suggestion, so state is States.CLASS
208 // eslint-disable-next-line complexity
210 if (!this.searchBox || !this.searchBox.value) {
214 const query = this.searchBox.value;
215 if (this._lastStateCheckAt == query) {
216 // If query is the same, return early.
219 this._lastStateCheckAt = query;
223 // Now we iterate over the query and decide the state character by
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) {
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;
248 this._state = this.States.TAG;
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
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;
263 this._state = this.States.CLASS;
269 if (subQuery.match(/[#]+[^#]*$/)[0].length > 2) {
270 // Checks whether the subQuery has atleast one [a-zA-Z] after the
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;
279 this._state = this.States.ID;
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;
294 this._state = this.States.ATTRIBUTE;
304 * Removes event listeners and cleans up references.
307 this.searchBox.removeEventListener("input", this.showSuggestions, true);
308 this.searchBox.removeEventListener(
310 this._onSearchKeypress,
313 this.inspector.off("markupmutation", this._onMarkupMutation);
314 this.searchPopup.destroy();
315 this.searchPopup = null;
316 this.searchBox = null;
317 this.panelDoc = null;
321 * Handles keypresses inside the input box.
323 _onSearchKeypress(event) {
324 const popup = this.searchPopup;
325 switch (event.keyCode) {
326 case KeyCodes.DOM_VK_RETURN:
327 case KeyCodes.DOM_VK_TAB:
329 if (popup.selectedItem) {
330 this.searchBox.value = popup.selectedItem.label;
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");
342 case KeyCodes.DOM_VK_UP:
343 if (popup.isOpen && popup.itemCount > 0) {
344 popup.selectPreviousItem();
345 this.searchBox.value = popup.selectedItem.label;
349 case KeyCodes.DOM_VK_DOWN:
350 if (popup.isOpen && popup.itemCount > 0) {
351 popup.selectNextItem();
352 this.searchBox.value = popup.selectedItem.label;
356 case KeyCodes.DOM_VK_ESCAPE:
360 this.emitForTests("processing-done");
369 event.preventDefault();
370 event.stopPropagation();
371 this.emitForTests("processing-done");
375 * Handles click events from the autocomplete popup.
377 _onSearchPopupClick(event) {
378 const selectedItem = this.searchPopup.selectedItem;
380 this.searchBox.value = selectedItem.label;
384 event.preventDefault();
385 event.stopPropagation();
389 * Reset previous search results on markup-mutations to make sure we search
390 * again after nodes have been added/removed/changed.
392 _onMarkupMutation() {
393 this._searchResults = null;
394 this._lastSearched = null;
398 * Populates the suggestions list and show the suggestion popup.
400 * @return {Promise} promise that will resolve when the autocomplete popup is fully
401 * displayed or hidden.
403 _showPopup(list, popupState) {
405 const query = this.searchBox.value;
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;
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;
436 if (popupState === this.States.TAG && state === this.States.ID) {
437 item.preLabel = "#" + item.preLabel;
441 if (++total > MAX_SUGGESTIONS - 1) {
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.
452 this.searchPopup.openPopup(this.searchBox, xOffset);
454 this.searchPopup.hidePopup();
455 return onPopupOpened;
458 return this.hidePopup();
462 * Hide the suggestion popup if necessary.
465 const onPopupClosed = this.searchPopup.once("popup-closed");
466 this.searchPopup.hidePopup();
467 return onPopupClosed;
471 * Suggests classes,ids and tags based on the user input as user types in the
474 async showSuggestions() {
475 let query = this.searchBox.value;
476 const originalQuery = this.searchBox.value;
478 const state = this.state;
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).
486 this.emitForTests("processing-done", { query: originalQuery });
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);
504 // TODO: implement some caching so that over the wire request is not made
506 if (/[\s+>~]$/.test(query)) {
511 await this.inspector.commands.inspectorCommand.getSuggestionsForQuery(
517 if (state === this.States.CLASS) {
518 firstPart = "." + firstPart;
519 } else if (state === this.States.ID) {
520 firstPart = "#" + firstPart;
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) {
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 });