inspector-search.js (21079B)
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 { KeyCodes } = require("resource://devtools/client/shared/keycodes.js"); 8 9 const EventEmitter = require("resource://devtools/shared/event-emitter.js"); 10 const AutocompletePopup = require("resource://devtools/client/shared/autocomplete-popup.js"); 11 12 // Maximum number of selector suggestions shown in the panel. 13 const MAX_SUGGESTIONS = 15; 14 15 /** 16 * Converts any input field into a document search box. 17 * 18 * Emits the following events: 19 * - search-cleared: when the search box is emptied 20 * - search-result: when a search is made and a result is selected 21 */ 22 class InspectorSearch { 23 /** 24 * @param {InspectorPanel} inspector 25 * The InspectorPanel to access the inspector commands for 26 * search and document traversal. 27 * @param {HTMLElement} input 28 * The input element to which the panel will be attached and from where 29 * search input will be taken. 30 * @param {HTMLElement} clearBtn 31 * The clear button in the input field that will clear the input value. 32 * @param {HTMLElement} prevBtn 33 * The prev button in the search label that will move 34 * selection to previous match. 35 * @param {HTMLElement} nextBtn 36 * The next button in the search label that will move 37 * selection to next match. 38 */ 39 constructor(inspector, input, clearBtn, prevBtn, nextBtn) { 40 this.inspector = inspector; 41 this.searchBox = input; 42 this.searchClearButton = clearBtn; 43 this.searchPrevButton = prevBtn; 44 this.searchNextButton = nextBtn; 45 this._lastSearched = null; 46 47 this._onKeyDown = this._onKeyDown.bind(this); 48 this._onInput = this._onInput.bind(this); 49 this.findPrev = this.findPrev.bind(this); 50 this.findNext = this.findNext.bind(this); 51 this._onClearSearch = this._onClearSearch.bind(this); 52 53 this.searchBox.addEventListener("keydown", this._onKeyDown, true); 54 this.searchBox.addEventListener("input", this._onInput, true); 55 this.searchPrevButton.addEventListener("click", this.findPrev, true); 56 this.searchNextButton.addEventListener("click", this.findNext, true); 57 this.searchClearButton.addEventListener("click", this._onClearSearch); 58 59 this.autocompleter = new SelectorAutocompleter(inspector, input); 60 EventEmitter.decorate(this); 61 } 62 destroy() { 63 this.searchBox.removeEventListener("keydown", this._onKeyDown, true); 64 this.searchBox.removeEventListener("input", this._onInput, true); 65 this.searchPrevButton.removeEventListener("click", this.findPrev, true); 66 this.searchNextButton.removeEventListener("click", this.findNext, true); 67 this.searchClearButton.removeEventListener("click", this._onClearSearch); 68 this.searchBox = null; 69 this.searchPrevButton = null; 70 this.searchNextButton = null; 71 this.searchClearButton = null; 72 this.autocompleter.destroy(); 73 } 74 75 _onSearch(reverse = false) { 76 this.doFullTextSearch(this.searchBox.value, reverse).catch(console.error); 77 } 78 79 async doFullTextSearch(query, reverse) { 80 const lastSearched = this._lastSearched; 81 this._lastSearched = query; 82 83 const searchContainer = this.searchBox.parentNode; 84 85 if (query.length === 0) { 86 searchContainer.classList.remove("devtools-searchbox-no-match"); 87 if (!lastSearched || lastSearched.length) { 88 this.emit("search-cleared"); 89 } 90 return; 91 } 92 93 const res = await this.inspector.commands.inspectorCommand.findNextNode( 94 query, 95 { 96 reverse, 97 } 98 ); 99 100 // Value has changed since we started this request, we're done. 101 if (query !== this.searchBox.value) { 102 return; 103 } 104 105 if (res) { 106 this.inspector.selection.setNodeFront(res.node, { 107 reason: "inspectorsearch", 108 searchQuery: query, 109 }); 110 searchContainer.classList.remove("devtools-searchbox-no-match"); 111 res.query = query; 112 this.emit("search-result", res); 113 } else { 114 searchContainer.classList.add("devtools-searchbox-no-match"); 115 this.emit("search-result"); 116 } 117 } 118 119 _onInput() { 120 if (this.searchBox.value.length === 0) { 121 this.searchClearButton.hidden = true; 122 this._onSearch(); 123 } else { 124 this.searchClearButton.hidden = false; 125 } 126 } 127 128 _onKeyDown(event) { 129 if (event.keyCode === KeyCodes.DOM_VK_RETURN) { 130 this._onSearch(event.shiftKey); 131 } 132 133 const modifierKey = 134 Services.appinfo.OS === "Darwin" ? event.metaKey : event.ctrlKey; 135 if (event.keyCode === KeyCodes.DOM_VK_G && modifierKey) { 136 this._onSearch(event.shiftKey); 137 event.preventDefault(); 138 } 139 140 // The search box is a `<input type="search">`, which would clear on pressing Escape. 141 // Prevent the clear action from happening when the autocomplete popup is visible and 142 // only hide it. The preventDefault has to be called during keydown and not keypress 143 // to avoid the input clearance. 144 if ( 145 event.keyCode === KeyCodes.DOM_VK_ESCAPE && 146 this.autocompleter.searchPopup?.isOpen 147 ) { 148 event.preventDefault(); 149 this.autocompleter.hidePopup(); 150 } 151 } 152 153 findNext() { 154 this._onSearch(); 155 } 156 157 findPrev() { 158 this._onSearch(true); 159 } 160 161 _onClearSearch() { 162 this.searchBox.parentNode.classList.remove("devtools-searchbox-no-match"); 163 this.searchBox.value = ""; 164 this.searchBox.focus(); 165 this.searchClearButton.hidden = true; 166 this.emit("search-cleared"); 167 } 168 } 169 170 exports.InspectorSearch = InspectorSearch; 171 172 /** 173 * Converts any input box on a page to a CSS selector search and suggestion box. 174 * 175 * Emits 'processing-done' event when it is done processing the current 176 * keypress, search request or selection from the list, whether that led to a 177 * search or not. 178 */ 179 class SelectorAutocompleter extends EventEmitter { 180 /** 181 * @param {InspectorPanel} inspector 182 * The InspectorPanel to access the inspector commands for 183 * search and document traversal. 184 * @param {HTMLElement} inputNode 185 * The input element to which the panel will be attached and from where 186 * search input will be taken. 187 */ 188 constructor(inspector, inputNode) { 189 super(); 190 191 this.inspector = inspector; 192 this.searchBox = inputNode; 193 this.panelDoc = this.searchBox.ownerDocument; 194 195 this.showSuggestions = this.showSuggestions.bind(this); 196 197 // Options for the AutocompletePopup. 198 const options = { 199 listId: "searchbox-panel-listbox", 200 autoSelect: true, 201 position: "top", 202 onClick: this.#onSearchPopupClick, 203 }; 204 205 // The popup will be attached to the toolbox document. 206 this.searchPopup = new AutocompletePopup(inspector.toolbox.doc, options); 207 208 this.searchBox.addEventListener("input", this.showSuggestions, true); 209 this.searchBox.addEventListener("keypress", this.#onSearchKeypress, true); 210 } 211 212 // The possible states of the query. 213 States = { 214 CLASS: "class", 215 ID: "id", 216 TAG: "tag", 217 ATTRIBUTE: "attribute", 218 // This is for pseudo classes (e.g. `:active`, `:not()`). We keep it as "pseudo" as 219 // the server handles both pseudo elements and pseudo classes under the same type 220 PSEUDO_CLASS: "pseudo", 221 // This is for pseudo element (e.g. `::selection`) 222 PSEUDO_ELEMENT: "pseudo-element", 223 }; 224 225 // The current state of the query. 226 #state = null; 227 228 // The query corresponding to last state computation. 229 #lastStateCheckAt = null; 230 231 get walker() { 232 return this.inspector.walker; 233 } 234 235 /** 236 * Computes the state of the query. State refers to whether the query 237 * currently requires a class suggestion, or a tag, or an Id suggestion. 238 * This getter will effectively compute the state by traversing the query 239 * character by character each time the query changes. 240 * 241 * @example 242 * '#f' requires an Id suggestion, so the state is States.ID 243 * 'div > .foo' requires class suggestion, so state is States.CLASS 244 */ 245 // eslint-disable-next-line complexity 246 get state() { 247 if (!this.searchBox || !this.searchBox.value) { 248 return null; 249 } 250 251 const query = this.searchBox.value; 252 if (this.#lastStateCheckAt == query) { 253 // If query is the same, return early. 254 return this.#state; 255 } 256 this.#lastStateCheckAt = query; 257 258 this.#state = null; 259 let subQuery = ""; 260 // Now we iterate over the query and decide the state character by 261 // character. 262 // The logic here is that while iterating, the state can go from one to 263 // another with some restrictions. Like, if the state is Class, then it can 264 // never go to Tag state without a space or '>' character; Or like, a Class 265 // state with only '.' cannot go to an Id state without any [a-zA-Z] after 266 // the '.' which means that '.#' is a selector matching a class name '#'. 267 // Similarily for '#.' which means a selctor matching an id '.'. 268 for (let i = 1; i <= query.length; i++) { 269 // Calculate the state. 270 subQuery = query.slice(0, i); 271 let [secondLastChar, lastChar] = subQuery.slice(-2); 272 switch (this.#state) { 273 case null: 274 // This will happen only in the first iteration of the for loop. 275 lastChar = secondLastChar; 276 277 case this.States.TAG: // eslint-disable-line 278 if (lastChar === ".") { 279 this.#state = this.States.CLASS; 280 } else if (lastChar === "#") { 281 this.#state = this.States.ID; 282 } else if (lastChar === "[") { 283 this.#state = this.States.ATTRIBUTE; 284 } else if (lastChar === ":") { 285 this.#state = this.States.PSEUDO_CLASS; 286 } else if (lastChar === ")") { 287 this.#state = null; 288 } else { 289 this.#state = this.States.TAG; 290 } 291 break; 292 293 case this.States.CLASS: 294 if (subQuery.match(/[\.]+[^\.]*$/)[0].length > 2) { 295 // Checks whether the subQuery has atleast one [a-zA-Z] after the 296 // '.'. 297 if (lastChar === " " || lastChar === ">") { 298 this.#state = this.States.TAG; 299 } else if (lastChar === "#") { 300 this.#state = this.States.ID; 301 } else if (lastChar === "[") { 302 this.#state = this.States.ATTRIBUTE; 303 } else if (lastChar === ":") { 304 this.#state = this.States.PSEUDO_CLASS; 305 } else if (lastChar === ")") { 306 this.#state = null; 307 } else { 308 this.#state = this.States.CLASS; 309 } 310 } 311 break; 312 313 case this.States.ID: 314 if (subQuery.match(/[#]+[^#]*$/)[0].length > 2) { 315 // Checks whether the subQuery has atleast one [a-zA-Z] after the 316 // '#'. 317 if (lastChar === " " || lastChar === ">") { 318 this.#state = this.States.TAG; 319 } else if (lastChar === ".") { 320 this.#state = this.States.CLASS; 321 } else if (lastChar === "[") { 322 this.#state = this.States.ATTRIBUTE; 323 } else if (lastChar === ":") { 324 this.#state = this.States.PSEUDO_CLASS; 325 } else if (lastChar === ")") { 326 this.#state = null; 327 } else { 328 this.#state = this.States.ID; 329 } 330 } 331 break; 332 333 case this.States.ATTRIBUTE: 334 if (subQuery.match(/[\[][^\]]+[\]]/) !== null) { 335 // Checks whether the subQuery has at least one ']' after the '['. 336 if (lastChar === " " || lastChar === ">") { 337 this.#state = this.States.TAG; 338 } else if (lastChar === ".") { 339 this.#state = this.States.CLASS; 340 } else if (lastChar === "#") { 341 this.#state = this.States.ID; 342 } else if (lastChar === ":") { 343 this.#state = this.States.PSEUDO_CLASS; 344 } else if (lastChar === ")") { 345 this.#state = null; 346 } else { 347 this.#state = this.States.ATTRIBUTE; 348 } 349 } 350 break; 351 352 case this.States.PSEUDO_CLASS: 353 if (lastChar === ":" && secondLastChar === ":") { 354 // We don't support searching for pseudo elements, so bail out when we 355 // see `::` 356 this.#state = this.States.PSEUDO_ELEMENT; 357 return this.#state; 358 } 359 360 if (lastChar === "(") { 361 this.#state = null; 362 } else if (lastChar === ".") { 363 this.#state = this.States.CLASS; 364 } else if (lastChar === "#") { 365 this.#state = this.States.ID; 366 } else { 367 this.#state = this.States.PSEUDO_CLASS; 368 } 369 break; 370 } 371 } 372 return this.#state; 373 } 374 375 /** 376 * Removes event listeners and cleans up references. 377 */ 378 destroy() { 379 this.searchBox.removeEventListener("input", this.showSuggestions, true); 380 this.searchBox.removeEventListener( 381 "keypress", 382 this.#onSearchKeypress, 383 true 384 ); 385 this.searchPopup.destroy(); 386 this.searchPopup = null; 387 this.searchBox = null; 388 this.panelDoc = null; 389 } 390 391 /** 392 * Handles keypresses inside the input box. 393 */ 394 #onSearchKeypress = event => { 395 const popup = this.searchPopup; 396 switch (event.keyCode) { 397 case KeyCodes.DOM_VK_RETURN: 398 case KeyCodes.DOM_VK_TAB: 399 if (popup.isOpen) { 400 if (popup.selectedItem) { 401 this.searchBox.value = popup.selectedItem.label; 402 } 403 this.hidePopup(); 404 } else if (!popup.isOpen) { 405 // When tab is pressed with focus on searchbox and closed popup, 406 // do not prevent the default to avoid a keyboard trap and move focus 407 // to next/previous element. 408 this.emitForTests("processing-done"); 409 return; 410 } 411 break; 412 413 case KeyCodes.DOM_VK_UP: 414 if (popup.isOpen && popup.itemCount > 0) { 415 popup.selectPreviousItem(); 416 this.searchBox.value = popup.selectedItem.label; 417 } 418 break; 419 420 case KeyCodes.DOM_VK_DOWN: 421 if (popup.isOpen && popup.itemCount > 0) { 422 popup.selectNextItem(); 423 this.searchBox.value = popup.selectedItem.label; 424 } 425 break; 426 427 case KeyCodes.DOM_VK_ESCAPE: 428 if (popup.isOpen) { 429 this.hidePopup(); 430 } else { 431 this.emitForTests("processing-done"); 432 return; 433 } 434 break; 435 436 default: 437 return; 438 } 439 440 event.preventDefault(); 441 event.stopPropagation(); 442 this.emitForTests("processing-done"); 443 }; 444 445 /** 446 * Handles click events from the autocomplete popup. 447 */ 448 #onSearchPopupClick = event => { 449 const selectedItem = this.searchPopup.selectedItem; 450 if (selectedItem) { 451 this.searchBox.value = selectedItem.label; 452 } 453 this.hidePopup(); 454 455 event.preventDefault(); 456 event.stopPropagation(); 457 }; 458 459 /** 460 * Populates the suggestions list and show the suggestion popup. 461 * 462 * @param {Array} suggestions: List of suggestions 463 * @param {string | null} popupState: One of SelectorAutocompleter.States 464 * @return {Promise} promise that will resolve when the autocomplete popup is fully 465 * displayed or hidden. 466 */ 467 #showPopup(suggestions, popupState) { 468 let total = 0; 469 const query = this.searchBox.value; 470 const items = []; 471 472 for (let [value, state] of suggestions) { 473 if (popupState === this.States.PSEUDO_CLASS) { 474 value = query.substring(0, query.lastIndexOf(":")) + value; 475 } else if (query.match(/[\s>+~]$/)) { 476 // for cases like 'div ', 'div >', 'div+' or 'div~' 477 value = query + value; 478 } else if (query.match(/[\s>+~][\.#a-zA-Z][^\s>+~\.#\[]*$/)) { 479 // for cases like 'div #a' or 'div .a' or 'div > d' and likewise 480 const lastPart = query.match(/[\s>+~][\.#a-zA-Z][^\s>+~\.#\[]*$/)[0]; 481 value = query.slice(0, -1 * lastPart.length + 1) + value; 482 } else if (query.match(/[a-zA-Z][#\.][^#\.\s+>~]*$/)) { 483 // for cases like 'div.class' or '#foo.bar' and likewise 484 const lastPart = query.match(/[a-zA-Z][#\.][^#\.\s+>~]*$/)[0]; 485 value = query.slice(0, -1 * lastPart.length + 1) + value; 486 } else if (query.match(/[a-zA-Z]*\[[^\]]*\][^\]]*/)) { 487 // for cases like '[foo].bar' and likewise 488 const attrPart = query.substring(0, query.lastIndexOf("]") + 1); 489 value = attrPart + value; 490 } 491 492 const item = { 493 preLabel: query, 494 label: value, 495 }; 496 497 // In case the query's state is tag and the item's state is id or class 498 // adjust the preLabel 499 if (popupState === this.States.TAG && state === this.States.CLASS) { 500 item.preLabel = "." + item.preLabel; 501 } 502 if (popupState === this.States.TAG && state === this.States.ID) { 503 item.preLabel = "#" + item.preLabel; 504 } 505 506 items.push(item); 507 if (++total > MAX_SUGGESTIONS - 1) { 508 break; 509 } 510 } 511 512 if (total > 0) { 513 const onPopupOpened = this.searchPopup.once("popup-opened"); 514 this.searchPopup.once("popup-closed", () => { 515 this.searchPopup.setItems(items); 516 // The offset is left padding (22px) + left border width (1px) of searchBox. 517 const xOffset = 23; 518 this.searchPopup.openPopup(this.searchBox, xOffset); 519 }); 520 this.searchPopup.hidePopup(); 521 return onPopupOpened; 522 } 523 524 return this.hidePopup(); 525 } 526 527 /** 528 * Hide the suggestion popup if necessary. 529 */ 530 hidePopup() { 531 const onPopupClosed = this.searchPopup.once("popup-closed"); 532 this.searchPopup.hidePopup(); 533 return onPopupClosed; 534 } 535 536 /** 537 * Suggests classes,ids and tags based on the user input as user types in the 538 * searchbox. 539 */ 540 async showSuggestions() { 541 let query = this.searchBox.value; 542 const originalQuery = this.searchBox.value; 543 544 const state = this.state; 545 let completing = ""; 546 547 if ( 548 // Hide the popup if: 549 // - the query is empty 550 !query || 551 // - the query ends with * (because we don't want to suggest all nodes) 552 query.endsWith("*") || 553 // - if it is an attribute selector (because it would give a lot of useless results). 554 state === this.States.ATTRIBUTE || 555 // - if it is a pseudo element selector (we don't support it, see Bug 1097991) 556 state === this.States.PSEUDO_ELEMENT 557 ) { 558 this.hidePopup(); 559 this.emitForTests("processing-done", { query: originalQuery }); 560 return; 561 } 562 563 if (state === this.States.TAG) { 564 // gets the tag that is being completed. For ex: 565 // - 'di' returns 'di' 566 // - 'div.foo s' returns 's' 567 // - 'div.foo > s' returns 's' 568 // - 'div.foo + s' returns 's' 569 // - 'div.foo ~ s' returns 's' 570 // - 'div.foo x-el_1' returns 'x-el_1' 571 const matches = query.match(/[\s>+~]?(?<tag>[a-zA-Z0-9_-]*)$/); 572 completing = matches.groups.tag; 573 query = query.slice(0, query.length - completing.length); 574 } else if (state === this.States.CLASS) { 575 // gets the class that is being completed. For ex. '.foo.b' returns 'b' 576 completing = query.match(/\.([^\.]*)$/)[1]; 577 query = query.slice(0, query.length - completing.length - 1); 578 } else if (state === this.States.ID) { 579 // gets the id that is being completed. For ex. '.foo#b' returns 'b' 580 completing = query.match(/#([^#]*)$/)[1]; 581 query = query.slice(0, query.length - completing.length - 1); 582 } else if (state === this.States.PSEUDO_CLASS) { 583 // The getSuggestionsForQuery expects a pseudo element without the : prefix 584 completing = query.substring(query.lastIndexOf(":") + 1); 585 query = ""; 586 } 587 // TODO: implement some caching so that over the wire request is not made 588 // everytime. 589 if (/[\s+>~]$/.test(query)) { 590 query += "*"; 591 } 592 593 let suggestions = 594 await this.inspector.commands.inspectorCommand.getSuggestionsForQuery( 595 query, 596 completing, 597 state 598 ); 599 600 if (state === this.States.CLASS) { 601 completing = "." + completing; 602 } else if (state === this.States.ID) { 603 completing = "#" + completing; 604 } else if (state === this.States.PSEUDO_CLASS) { 605 completing = ":" + completing; 606 // Remove pseudo-element suggestions, since the search does not work with them (Bug 1097991) 607 suggestions = suggestions.filter( 608 suggestion => !suggestion[0].startsWith("::") 609 ); 610 } 611 612 // If there is a single tag match and it's what the user typed, then 613 // don't need to show a popup. 614 if (suggestions.length === 1 && suggestions[0][0] === completing) { 615 suggestions = []; 616 } 617 618 // Wait for the autocomplete-popup to fire its popup-opened event, to make sure 619 // the autoSelect item has been selected. 620 await this.#showPopup(suggestions, state); 621 this.emitForTests("processing-done", { query: originalQuery }); 622 } 623 } 624 625 exports.SelectorAutocompleter = SelectorAutocompleter;