610 lines
20 KiB
JavaScript
610 lines
20 KiB
JavaScript
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
|
|
|
"use strict";
|
|
|
|
const { KeyCodes } = require("resource://devtools/client/shared/keycodes.js");
|
|
|
|
const EventEmitter = require("resource://devtools/shared/event-emitter.js");
|
|
const AutocompletePopup = require("resource://devtools/client/shared/autocomplete-popup.js");
|
|
|
|
// Maximum number of selector suggestions shown in the panel.
|
|
const MAX_SUGGESTIONS = 15;
|
|
|
|
/**
|
|
* Converts any input field into a document search box.
|
|
*
|
|
* @param {InspectorPanel} inspector
|
|
* The InspectorPanel to access the inspector commands for
|
|
* search and document traversal.
|
|
* @param {DOMNode} input
|
|
* The input element to which the panel will be attached and from where
|
|
* search input will be taken.
|
|
* @param {DOMNode} clearBtn
|
|
* The clear button in the input field that will clear the input value.
|
|
* @param {DOMNode} prevBtn
|
|
* The prev button in the search label that will move
|
|
* selection to previous match.
|
|
* @param {DOMNode} nextBtn
|
|
* The next button in the search label that will move
|
|
* selection to next match.
|
|
*
|
|
* Emits the following events:
|
|
* - search-cleared: when the search box is emptied
|
|
* - search-result: when a search is made and a result is selected
|
|
*/
|
|
function InspectorSearch(inspector, input, clearBtn, prevBtn, nextBtn) {
|
|
this.inspector = inspector;
|
|
this.searchBox = input;
|
|
this.searchClearButton = clearBtn;
|
|
this.searchPrevButton = prevBtn;
|
|
this.searchNextButton = nextBtn;
|
|
this._lastSearched = null;
|
|
|
|
this._onKeyDown = this._onKeyDown.bind(this);
|
|
this._onInput = this._onInput.bind(this);
|
|
this.findPrev = this.findPrev.bind(this);
|
|
this.findNext = this.findNext.bind(this);
|
|
this._onClearSearch = this._onClearSearch.bind(this);
|
|
|
|
this.searchBox.addEventListener("keydown", this._onKeyDown, true);
|
|
this.searchBox.addEventListener("input", this._onInput, true);
|
|
this.searchPrevButton.addEventListener("click", this.findPrev, true);
|
|
this.searchNextButton.addEventListener("click", this.findNext, true);
|
|
this.searchClearButton.addEventListener("click", this._onClearSearch);
|
|
|
|
this.autocompleter = new SelectorAutocompleter(inspector, input);
|
|
EventEmitter.decorate(this);
|
|
}
|
|
|
|
exports.InspectorSearch = InspectorSearch;
|
|
|
|
InspectorSearch.prototype = {
|
|
destroy() {
|
|
this.searchBox.removeEventListener("keydown", this._onKeyDown, true);
|
|
this.searchBox.removeEventListener("input", this._onInput, true);
|
|
this.searchPrevButton.removeEventListener("click", this.findPrev, true);
|
|
this.searchNextButton.removeEventListener("click", this.findNext, true);
|
|
this.searchClearButton.removeEventListener("click", this._onClearSearch);
|
|
this.searchBox = null;
|
|
this.searchPrevButton = null;
|
|
this.searchNextButton = null;
|
|
this.searchClearButton = null;
|
|
this.autocompleter.destroy();
|
|
},
|
|
|
|
_onSearch(reverse = false) {
|
|
this.doFullTextSearch(this.searchBox.value, reverse).catch(console.error);
|
|
},
|
|
|
|
async doFullTextSearch(query, reverse) {
|
|
const lastSearched = this._lastSearched;
|
|
this._lastSearched = query;
|
|
|
|
const searchContainer = this.searchBox.parentNode;
|
|
|
|
if (query.length === 0) {
|
|
searchContainer.classList.remove("devtools-searchbox-no-match");
|
|
if (!lastSearched || lastSearched.length) {
|
|
this.emit("search-cleared");
|
|
}
|
|
return;
|
|
}
|
|
|
|
const res = await this.inspector.commands.inspectorCommand.findNextNode(
|
|
query,
|
|
{
|
|
reverse,
|
|
}
|
|
);
|
|
|
|
// Value has changed since we started this request, we're done.
|
|
if (query !== this.searchBox.value) {
|
|
return;
|
|
}
|
|
|
|
if (res) {
|
|
this.inspector.selection.setNodeFront(res.node, {
|
|
reason: "inspectorsearch",
|
|
});
|
|
searchContainer.classList.remove("devtools-searchbox-no-match");
|
|
res.query = query;
|
|
this.emit("search-result", res);
|
|
} else {
|
|
searchContainer.classList.add("devtools-searchbox-no-match");
|
|
this.emit("search-result");
|
|
}
|
|
},
|
|
|
|
_onInput() {
|
|
if (this.searchBox.value.length === 0) {
|
|
this.searchClearButton.hidden = true;
|
|
this._onSearch();
|
|
} else {
|
|
this.searchClearButton.hidden = false;
|
|
}
|
|
},
|
|
|
|
_onKeyDown(event) {
|
|
if (event.keyCode === KeyCodes.DOM_VK_RETURN) {
|
|
this._onSearch(event.shiftKey);
|
|
}
|
|
|
|
const modifierKey =
|
|
Services.appinfo.OS === "Darwin" ? event.metaKey : event.ctrlKey;
|
|
if (event.keyCode === KeyCodes.DOM_VK_G && modifierKey) {
|
|
this._onSearch(event.shiftKey);
|
|
event.preventDefault();
|
|
}
|
|
},
|
|
|
|
findNext() {
|
|
this._onSearch();
|
|
},
|
|
|
|
findPrev() {
|
|
this._onSearch(true);
|
|
},
|
|
|
|
_onClearSearch() {
|
|
this.searchBox.parentNode.classList.remove("devtools-searchbox-no-match");
|
|
this.searchBox.value = "";
|
|
this.searchBox.focus();
|
|
this.searchClearButton.hidden = true;
|
|
this.emit("search-cleared");
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Converts any input box on a page to a CSS selector search and suggestion box.
|
|
*
|
|
* Emits 'processing-done' event when it is done processing the current
|
|
* keypress, search request or selection from the list, whether that led to a
|
|
* search or not.
|
|
*
|
|
* @constructor
|
|
* @param InspectorPanel inspector
|
|
* The InspectorPanel to access the inspector commands for
|
|
* search and document traversal.
|
|
* @param nsiInputElement inputNode
|
|
* The input element to which the panel will be attached and from where
|
|
* search input will be taken.
|
|
*/
|
|
class SelectorAutocompleter extends EventEmitter {
|
|
constructor(inspector, inputNode) {
|
|
super();
|
|
|
|
this.inspector = inspector;
|
|
this.searchBox = inputNode;
|
|
this.panelDoc = this.searchBox.ownerDocument;
|
|
|
|
this.showSuggestions = this.showSuggestions.bind(this);
|
|
|
|
// Options for the AutocompletePopup.
|
|
const options = {
|
|
listId: "searchbox-panel-listbox",
|
|
autoSelect: true,
|
|
position: "top",
|
|
onClick: this.#onSearchPopupClick,
|
|
};
|
|
|
|
// The popup will be attached to the toolbox document.
|
|
this.searchPopup = new AutocompletePopup(inspector._toolbox.doc, options);
|
|
|
|
this.searchBox.addEventListener("input", this.showSuggestions, true);
|
|
this.searchBox.addEventListener("keypress", this.#onSearchKeypress, true);
|
|
}
|
|
|
|
// The possible states of the query.
|
|
States = {
|
|
CLASS: "class",
|
|
ID: "id",
|
|
TAG: "tag",
|
|
ATTRIBUTE: "attribute",
|
|
// This is for pseudo classes (e.g. `:active`, `:not()`). We keep it as "pseudo" as
|
|
// the server handles both pseudo elements and pseudo classes under the same type
|
|
PSEUDO_CLASS: "pseudo",
|
|
// This is for pseudo element (e.g. `::selection`)
|
|
PSEUDO_ELEMENT: "pseudo-element",
|
|
};
|
|
|
|
// The current state of the query.
|
|
#state = null;
|
|
|
|
// The query corresponding to last state computation.
|
|
#lastStateCheckAt = null;
|
|
|
|
get walker() {
|
|
return this.inspector.walker;
|
|
}
|
|
|
|
/**
|
|
* Computes the state of the query. State refers to whether the query
|
|
* currently requires a class suggestion, or a tag, or an Id suggestion.
|
|
* This getter will effectively compute the state by traversing the query
|
|
* character by character each time the query changes.
|
|
*
|
|
* @example
|
|
* '#f' requires an Id suggestion, so the state is States.ID
|
|
* 'div > .foo' requires class suggestion, so state is States.CLASS
|
|
*/
|
|
// eslint-disable-next-line complexity
|
|
get state() {
|
|
if (!this.searchBox || !this.searchBox.value) {
|
|
return null;
|
|
}
|
|
|
|
const query = this.searchBox.value;
|
|
if (this.#lastStateCheckAt == query) {
|
|
// If query is the same, return early.
|
|
return this.#state;
|
|
}
|
|
this.#lastStateCheckAt = query;
|
|
|
|
this.#state = null;
|
|
let subQuery = "";
|
|
// Now we iterate over the query and decide the state character by
|
|
// character.
|
|
// The logic here is that while iterating, the state can go from one to
|
|
// another with some restrictions. Like, if the state is Class, then it can
|
|
// never go to Tag state without a space or '>' character; Or like, a Class
|
|
// state with only '.' cannot go to an Id state without any [a-zA-Z] after
|
|
// the '.' which means that '.#' is a selector matching a class name '#'.
|
|
// Similarily for '#.' which means a selctor matching an id '.'.
|
|
for (let i = 1; i <= query.length; i++) {
|
|
// Calculate the state.
|
|
subQuery = query.slice(0, i);
|
|
let [secondLastChar, lastChar] = subQuery.slice(-2);
|
|
switch (this.#state) {
|
|
case null:
|
|
// This will happen only in the first iteration of the for loop.
|
|
lastChar = secondLastChar;
|
|
|
|
case this.States.TAG: // eslint-disable-line
|
|
if (lastChar === ".") {
|
|
this.#state = this.States.CLASS;
|
|
} else if (lastChar === "#") {
|
|
this.#state = this.States.ID;
|
|
} else if (lastChar === "[") {
|
|
this.#state = this.States.ATTRIBUTE;
|
|
} else if (lastChar === ":") {
|
|
this.#state = this.States.PSEUDO_CLASS;
|
|
} else if (lastChar === ")") {
|
|
this.#state = null;
|
|
} else {
|
|
this.#state = this.States.TAG;
|
|
}
|
|
break;
|
|
|
|
case this.States.CLASS:
|
|
if (subQuery.match(/[\.]+[^\.]*$/)[0].length > 2) {
|
|
// Checks whether the subQuery has atleast one [a-zA-Z] after the
|
|
// '.'.
|
|
if (lastChar === " " || lastChar === ">") {
|
|
this.#state = this.States.TAG;
|
|
} else if (lastChar === "#") {
|
|
this.#state = this.States.ID;
|
|
} else if (lastChar === "[") {
|
|
this.#state = this.States.ATTRIBUTE;
|
|
} else if (lastChar === ":") {
|
|
this.#state = this.States.PSEUDO_CLASS;
|
|
} else if (lastChar === ")") {
|
|
this.#state = null;
|
|
} else {
|
|
this.#state = this.States.CLASS;
|
|
}
|
|
}
|
|
break;
|
|
|
|
case this.States.ID:
|
|
if (subQuery.match(/[#]+[^#]*$/)[0].length > 2) {
|
|
// Checks whether the subQuery has atleast one [a-zA-Z] after the
|
|
// '#'.
|
|
if (lastChar === " " || lastChar === ">") {
|
|
this.#state = this.States.TAG;
|
|
} else if (lastChar === ".") {
|
|
this.#state = this.States.CLASS;
|
|
} else if (lastChar === "[") {
|
|
this.#state = this.States.ATTRIBUTE;
|
|
} else if (lastChar === ":") {
|
|
this.#state = this.States.PSEUDO_CLASS;
|
|
} else if (lastChar === ")") {
|
|
this.#state = null;
|
|
} else {
|
|
this.#state = this.States.ID;
|
|
}
|
|
}
|
|
break;
|
|
|
|
case this.States.ATTRIBUTE:
|
|
if (subQuery.match(/[\[][^\]]+[\]]/) !== null) {
|
|
// Checks whether the subQuery has at least one ']' after the '['.
|
|
if (lastChar === " " || lastChar === ">") {
|
|
this.#state = this.States.TAG;
|
|
} else if (lastChar === ".") {
|
|
this.#state = this.States.CLASS;
|
|
} else if (lastChar === "#") {
|
|
this.#state = this.States.ID;
|
|
} else if (lastChar === ":") {
|
|
this.#state = this.States.PSEUDO_CLASS;
|
|
} else if (lastChar === ")") {
|
|
this.#state = null;
|
|
} else {
|
|
this.#state = this.States.ATTRIBUTE;
|
|
}
|
|
}
|
|
break;
|
|
|
|
case this.States.PSEUDO_CLASS:
|
|
if (lastChar === ":" && secondLastChar === ":") {
|
|
// We don't support searching for pseudo elements, so bail out when we
|
|
// see `::`
|
|
this.#state = this.States.PSEUDO_ELEMENT;
|
|
return this.#state;
|
|
}
|
|
|
|
if (lastChar === "(") {
|
|
this.#state = null;
|
|
} else if (lastChar === ".") {
|
|
this.#state = this.States.CLASS;
|
|
} else if (lastChar === "#") {
|
|
this.#state = this.States.ID;
|
|
} else {
|
|
this.#state = this.States.PSEUDO_CLASS;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
return this.#state;
|
|
}
|
|
|
|
/**
|
|
* Removes event listeners and cleans up references.
|
|
*/
|
|
destroy() {
|
|
this.searchBox.removeEventListener("input", this.showSuggestions, true);
|
|
this.searchBox.removeEventListener(
|
|
"keypress",
|
|
this.#onSearchKeypress,
|
|
true
|
|
);
|
|
this.searchPopup.destroy();
|
|
this.searchPopup = null;
|
|
this.searchBox = null;
|
|
this.panelDoc = null;
|
|
}
|
|
|
|
/**
|
|
* Handles keypresses inside the input box.
|
|
*/
|
|
#onSearchKeypress = event => {
|
|
const popup = this.searchPopup;
|
|
switch (event.keyCode) {
|
|
case KeyCodes.DOM_VK_RETURN:
|
|
case KeyCodes.DOM_VK_TAB:
|
|
if (popup.isOpen) {
|
|
if (popup.selectedItem) {
|
|
this.searchBox.value = popup.selectedItem.label;
|
|
}
|
|
this.hidePopup();
|
|
} else if (!popup.isOpen) {
|
|
// When tab is pressed with focus on searchbox and closed popup,
|
|
// do not prevent the default to avoid a keyboard trap and move focus
|
|
// to next/previous element.
|
|
this.emitForTests("processing-done");
|
|
return;
|
|
}
|
|
break;
|
|
|
|
case KeyCodes.DOM_VK_UP:
|
|
if (popup.isOpen && popup.itemCount > 0) {
|
|
popup.selectPreviousItem();
|
|
this.searchBox.value = popup.selectedItem.label;
|
|
}
|
|
break;
|
|
|
|
case KeyCodes.DOM_VK_DOWN:
|
|
if (popup.isOpen && popup.itemCount > 0) {
|
|
popup.selectNextItem();
|
|
this.searchBox.value = popup.selectedItem.label;
|
|
}
|
|
break;
|
|
|
|
case KeyCodes.DOM_VK_ESCAPE:
|
|
if (popup.isOpen) {
|
|
this.hidePopup();
|
|
} else {
|
|
this.emitForTests("processing-done");
|
|
return;
|
|
}
|
|
break;
|
|
|
|
default:
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
this.emitForTests("processing-done");
|
|
};
|
|
|
|
/**
|
|
* Handles click events from the autocomplete popup.
|
|
*/
|
|
#onSearchPopupClick = event => {
|
|
const selectedItem = this.searchPopup.selectedItem;
|
|
if (selectedItem) {
|
|
this.searchBox.value = selectedItem.label;
|
|
}
|
|
this.hidePopup();
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
};
|
|
|
|
/**
|
|
* Populates the suggestions list and show the suggestion popup.
|
|
*
|
|
* @param {Array} suggestions: List of suggestions
|
|
* @param {String|null} popupState: One of SelectorAutocompleter.States
|
|
* @return {Promise} promise that will resolve when the autocomplete popup is fully
|
|
* displayed or hidden.
|
|
*/
|
|
#showPopup(suggestions, popupState) {
|
|
let total = 0;
|
|
const query = this.searchBox.value;
|
|
const items = [];
|
|
|
|
for (let [value, state] of suggestions) {
|
|
if (popupState === this.States.PSEUDO_CLASS) {
|
|
value = query.substring(0, query.lastIndexOf(":")) + value;
|
|
} else if (query.match(/[\s>+~]$/)) {
|
|
// for cases like 'div ', 'div >', 'div+' or 'div~'
|
|
value = query + value;
|
|
} else if (query.match(/[\s>+~][\.#a-zA-Z][^\s>+~\.#\[]*$/)) {
|
|
// for cases like 'div #a' or 'div .a' or 'div > d' and likewise
|
|
const lastPart = query.match(/[\s>+~][\.#a-zA-Z][^\s>+~\.#\[]*$/)[0];
|
|
value = query.slice(0, -1 * lastPart.length + 1) + value;
|
|
} else if (query.match(/[a-zA-Z][#\.][^#\.\s+>~]*$/)) {
|
|
// for cases like 'div.class' or '#foo.bar' and likewise
|
|
const lastPart = query.match(/[a-zA-Z][#\.][^#\.\s+>~]*$/)[0];
|
|
value = query.slice(0, -1 * lastPart.length + 1) + value;
|
|
} else if (query.match(/[a-zA-Z]*\[[^\]]*\][^\]]*/)) {
|
|
// for cases like '[foo].bar' and likewise
|
|
const attrPart = query.substring(0, query.lastIndexOf("]") + 1);
|
|
value = attrPart + value;
|
|
}
|
|
|
|
const item = {
|
|
preLabel: query,
|
|
label: value,
|
|
};
|
|
|
|
// In case the query's state is tag and the item's state is id or class
|
|
// adjust the preLabel
|
|
if (popupState === this.States.TAG && state === this.States.CLASS) {
|
|
item.preLabel = "." + item.preLabel;
|
|
}
|
|
if (popupState === this.States.TAG && state === this.States.ID) {
|
|
item.preLabel = "#" + item.preLabel;
|
|
}
|
|
|
|
items.push(item);
|
|
if (++total > MAX_SUGGESTIONS - 1) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (total > 0) {
|
|
const onPopupOpened = this.searchPopup.once("popup-opened");
|
|
this.searchPopup.once("popup-closed", () => {
|
|
this.searchPopup.setItems(items);
|
|
// The offset is left padding (22px) + left border width (1px) of searchBox.
|
|
const xOffset = 23;
|
|
this.searchPopup.openPopup(this.searchBox, xOffset);
|
|
});
|
|
this.searchPopup.hidePopup();
|
|
return onPopupOpened;
|
|
}
|
|
|
|
return this.hidePopup();
|
|
}
|
|
|
|
/**
|
|
* Hide the suggestion popup if necessary.
|
|
*/
|
|
hidePopup() {
|
|
const onPopupClosed = this.searchPopup.once("popup-closed");
|
|
this.searchPopup.hidePopup();
|
|
return onPopupClosed;
|
|
}
|
|
|
|
/**
|
|
* Suggests classes,ids and tags based on the user input as user types in the
|
|
* searchbox.
|
|
*/
|
|
async showSuggestions() {
|
|
let query = this.searchBox.value;
|
|
const originalQuery = this.searchBox.value;
|
|
|
|
const state = this.state;
|
|
let completing = "";
|
|
|
|
if (
|
|
// Hide the popup if:
|
|
// - the query ends with * (because we don't want to suggest all nodes)
|
|
query.endsWith("*") ||
|
|
// - if it is an attribute selector (because it would give a lot of useless results).
|
|
state === this.States.ATTRIBUTE ||
|
|
// - if it is a pseudo element selector (we don't support it, see Bug 1097991)
|
|
state === this.States.PSEUDO_ELEMENT
|
|
) {
|
|
this.hidePopup();
|
|
this.emitForTests("processing-done", { query: originalQuery });
|
|
return;
|
|
}
|
|
|
|
if (state === this.States.TAG) {
|
|
// gets the tag that is being completed. For ex:
|
|
// - 'di' returns 'di'
|
|
// - 'div.foo s' returns 's'
|
|
// - 'div.foo > s' returns 's'
|
|
// - 'div.foo + s' returns 's'
|
|
// - 'div.foo ~ s' returns 's'
|
|
// - 'div.foo x-el_1' returns 'x-el_1'
|
|
const matches = query.match(/[\s>+~]?(?<tag>[a-zA-Z0-9_-]*)$/);
|
|
completing = matches.groups.tag;
|
|
query = query.slice(0, query.length - completing.length);
|
|
} else if (state === this.States.CLASS) {
|
|
// gets the class that is being completed. For ex. '.foo.b' returns 'b'
|
|
completing = query.match(/\.([^\.]*)$/)[1];
|
|
query = query.slice(0, query.length - completing.length - 1);
|
|
} else if (state === this.States.ID) {
|
|
// gets the id that is being completed. For ex. '.foo#b' returns 'b'
|
|
completing = query.match(/#([^#]*)$/)[1];
|
|
query = query.slice(0, query.length - completing.length - 1);
|
|
} else if (state === this.States.PSEUDO_CLASS) {
|
|
// The getSuggestionsForQuery expects a pseudo element without the : prefix
|
|
completing = query.substring(query.lastIndexOf(":") + 1);
|
|
query = "";
|
|
}
|
|
// TODO: implement some caching so that over the wire request is not made
|
|
// everytime.
|
|
if (/[\s+>~]$/.test(query)) {
|
|
query += "*";
|
|
}
|
|
|
|
let suggestions =
|
|
await this.inspector.commands.inspectorCommand.getSuggestionsForQuery(
|
|
query,
|
|
completing,
|
|
state
|
|
);
|
|
|
|
if (state === this.States.CLASS) {
|
|
completing = "." + completing;
|
|
} else if (state === this.States.ID) {
|
|
completing = "#" + completing;
|
|
} else if (state === this.States.PSEUDO_CLASS) {
|
|
completing = ":" + completing;
|
|
// Remove pseudo-element suggestions, since the search does not work with them (Bug 1097991)
|
|
suggestions = suggestions.filter(
|
|
suggestion => !suggestion[0].startsWith("::")
|
|
);
|
|
}
|
|
|
|
// If there is a single tag match and it's what the user typed, then
|
|
// don't need to show a popup.
|
|
if (suggestions.length === 1 && suggestions[0][0] === completing) {
|
|
suggestions = [];
|
|
}
|
|
|
|
// Wait for the autocomplete-popup to fire its popup-opened event, to make sure
|
|
// the autoSelect item has been selected.
|
|
await this.#showPopup(suggestions, state);
|
|
this.emitForTests("processing-done", { query: originalQuery });
|
|
}
|
|
}
|
|
|
|
exports.SelectorAutocompleter = SelectorAutocompleter;
|