inspector-search.js 18 KB

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