diff options
Diffstat (limited to 'browser/devtools/inspector/selector-search.js')
-rw-r--r-- | browser/devtools/inspector/selector-search.js | 549 |
1 files changed, 549 insertions, 0 deletions
diff --git a/browser/devtools/inspector/selector-search.js b/browser/devtools/inspector/selector-search.js new file mode 100644 index 000000000..6e66d3643 --- /dev/null +++ b/browser/devtools/inspector/selector-search.js @@ -0,0 +1,549 @@ +/* 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 {Cu} = require("chrome");
+
+loader.lazyGetter(this, "AutocompletePopup", () => {
+ return Cu.import("resource:///modules/devtools/AutocompletePopup.jsm", {}).AutocompletePopup;
+});
+
+// Maximum number of selector suggestions shown in the panel.
+const MAX_SUGGESTIONS = 15;
+
+/**
+ * Converts any input box on a page to a CSS selector search and suggestion box.
+ *
+ * @constructor
+ * @param nsIDOMDocument aContentDocument
+ * The content document which inspector is attached to.
+ * @param nsiInputElement aInputNode
+ * The input element to which the panel will be attached and from where
+ * search input will be taken.
+ * @param Function aCallback
+ * The method to callback when a search is available.
+ * This method is called with the matched node as the first argument.
+ */
+function SelectorSearch(aContentDocument, aInputNode, aCallback) {
+ this.doc = aContentDocument;
+ this.callback = aCallback;
+ this.searchBox = aInputNode;
+ this.panelDoc = this.searchBox.ownerDocument;
+
+ // initialize variables.
+ this._lastSearched = null;
+ this._lastValidSearch = "";
+ this._lastToLastValidSearch = null;
+ this._searchResults = null;
+ this._searchSuggestions = {};
+ this._searchIndex = 0;
+
+ // bind!
+ this._showPopup = this._showPopup.bind(this);
+ this._onHTMLSearch = this._onHTMLSearch.bind(this);
+ this._onSearchKeypress = this._onSearchKeypress.bind(this);
+ this._onListBoxKeypress = this._onListBoxKeypress.bind(this);
+
+ // Options for the AutocompletePopup.
+ let options = {
+ panelId: "inspector-searchbox-panel",
+ listBoxId: "searchbox-panel-listbox",
+ fixedWidth: true,
+ autoSelect: true,
+ position: "before_start",
+ direction: "ltr",
+ onClick: this._onListBoxKeypress,
+ onKeypress: this._onListBoxKeypress,
+ };
+ this.searchPopup = new AutocompletePopup(this.panelDoc, options);
+
+ // event listeners.
+ this.searchBox.addEventListener("command", this._onHTMLSearch, true);
+ this.searchBox.addEventListener("keypress", this._onSearchKeypress, true);
+}
+
+exports.SelectorSearch = SelectorSearch;
+
+SelectorSearch.prototype = {
+
+ // The possible states of the query.
+ States: {
+ CLASS: "class",
+ ID: "id",
+ TAG: "tag",
+ },
+
+ // The current state of the query.
+ _state: null,
+
+ // The query corresponding to last state computation.
+ _lastStateCheckAt: null,
+
+ /**
+ * 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
+ */
+ get state() {
+ if (!this.searchBox || !this.searchBox.value) {
+ return null;
+ }
+
+ let 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:
+ this._state = lastChar == "."
+ ? this.States.CLASS
+ : lastChar == "#"
+ ? this.States.ID
+ : 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 '.'.
+ this._state = (lastChar == " " || lastChar == ">")
+ ? this.States.TAG
+ : lastChar == "#"
+ ? this.States.ID
+ : 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 '#'.
+ this._state = (lastChar == " " || lastChar == ">")
+ ? this.States.TAG
+ : lastChar == "."
+ ? this.States.CLASS
+ : this.States.ID;
+ }
+ break;
+ }
+ }
+ return this._state;
+ },
+
+ /**
+ * Removes event listeners and cleans up references.
+ */
+ destroy: function SelectorSearch_destroy() {
+ // event listeners.
+ this.searchBox.removeEventListener("command", this._onHTMLSearch, true);
+ this.searchBox.removeEventListener("keypress", this._onSearchKeypress, true);
+ this.searchPopup.destroy();
+ this.searchPopup = null;
+ this.searchBox = null;
+ this.doc = null;
+ this.panelDoc = null;
+ this._searchResults = null;
+ this._searchSuggestions = null;
+ this.callback = null;
+ },
+
+ /**
+ * The command callback for the input box. This function is automatically
+ * invoked as the user is typing if the input box type is search.
+ */
+ _onHTMLSearch: function SelectorSearch__onHTMLSearch() {
+ let query = this.searchBox.value;
+ if (query == this._lastSearched) {
+ return;
+ }
+ this._lastSearched = query;
+ this._searchIndex = 0;
+
+ if (query.length == 0) {
+ this._lastValidSearch = "";
+ this.searchBox.removeAttribute("filled");
+ this.searchBox.classList.remove("devtools-no-search-result");
+ if (this.searchPopup.isOpen) {
+ this.searchPopup.hidePopup();
+ }
+ return;
+ }
+
+ this.searchBox.setAttribute("filled", true);
+ try {
+ this._searchResults = this.doc.querySelectorAll(query);
+ }
+ catch (ex) {
+ this._searchResults = [];
+ }
+ if (this._searchResults.length > 0) {
+ this._lastValidSearch = query;
+ // Even though the selector matched atleast one node, there is still
+ // possibility of suggestions.
+ if (query.match(/[\s>+]$/)) {
+ // If the query has a space or '>' at the end, create a selector to match
+ // the children of the selector inside the search box by adding a '*'.
+ this._lastValidSearch += "*";
+ }
+ else if (query.match(/[\s>+][\.#a-zA-Z][\.#>\s+]*$/)) {
+ // If the query is a partial descendant selector which does not matches
+ // any node, remove the last incomplete part and add a '*' to match
+ // everything. For ex, convert 'foo > b' to 'foo > *' .
+ let lastPart = query.match(/[\s>+][\.#a-zA-Z][^>\s+]*$/)[0];
+ this._lastValidSearch = query.slice(0, -1 * lastPart.length + 1) + "*";
+ }
+
+ if (!query.slice(-1).match(/[\.#\s>+]/)) {
+ // Hide the popup if we have some matching nodes and the query is not
+ // ending with [.# >] which means that the selector is not at the
+ // beginning of a new class, tag or id.
+ if (this.searchPopup.isOpen) {
+ this.searchPopup.hidePopup();
+ }
+ }
+ else {
+ this.showSuggestions();
+ }
+ this.searchBox.classList.remove("devtools-no-search-result");
+ this.callback(this._searchResults[0]);
+ }
+ else {
+ if (query.match(/[\s>+]$/)) {
+ this._lastValidSearch = query + "*";
+ }
+ else if (query.match(/[\s>+][\.#a-zA-Z][\.#>\s+]*$/)) {
+ let lastPart = query.match(/[\s+>][\.#a-zA-Z][^>\s+]*$/)[0];
+ this._lastValidSearch = query.slice(0, -1 * lastPart.length + 1) + "*";
+ }
+ this.searchBox.classList.add("devtools-no-search-result");
+ this.showSuggestions();
+ }
+ },
+
+ /**
+ * Handles keypresses inside the input box.
+ */
+ _onSearchKeypress: function SelectorSearch__onSearchKeypress(aEvent) {
+ let query = this.searchBox.value;
+ switch(aEvent.keyCode) {
+ case aEvent.DOM_VK_ENTER:
+ case aEvent.DOM_VK_RETURN:
+ if (query == this._lastSearched) {
+ this._searchIndex = (this._searchIndex + 1) % this._searchResults.length;
+ }
+ else {
+ this._onHTMLSearch();
+ return;
+ }
+ break;
+
+ case aEvent.DOM_VK_UP:
+ if (this.searchPopup.isOpen && this.searchPopup.itemCount > 0) {
+ this.searchPopup.focus();
+ if (this.searchPopup.selectedIndex == this.searchPopup.itemCount - 1) {
+ this.searchPopup.selectedIndex =
+ Math.max(0, this.searchPopup.itemCount - 2);
+ }
+ else {
+ this.searchPopup.selectedIndex = this.searchPopup.itemCount - 1;
+ }
+ this.searchBox.value = this.searchPopup.selectedItem.label;
+ }
+ else if (--this._searchIndex < 0) {
+ this._searchIndex = this._searchResults.length - 1;
+ }
+ break;
+
+ case aEvent.DOM_VK_DOWN:
+ if (this.searchPopup.isOpen && this.searchPopup.itemCount > 0) {
+ this.searchPopup.focus();
+ this.searchPopup.selectedIndex = 0;
+ this.searchBox.value = this.searchPopup.selectedItem.label;
+ }
+ this._searchIndex = (this._searchIndex + 1) % this._searchResults.length;
+ break;
+
+ case aEvent.DOM_VK_TAB:
+ if (this.searchPopup.isOpen &&
+ this.searchPopup.getItemAtIndex(this.searchPopup.itemCount - 1)
+ .preLabel == query) {
+ this.searchPopup.selectedIndex = this.searchPopup.itemCount - 1;
+ this.searchBox.value = this.searchPopup.selectedItem.label;
+ this._onHTMLSearch();
+ }
+ break;
+
+ case aEvent.DOM_VK_BACK_SPACE:
+ case aEvent.DOM_VK_DELETE:
+ // need to throw away the lastValidSearch.
+ this._lastToLastValidSearch = null;
+ // This gets the most complete selector from the query. For ex.
+ // '.foo.ba' returns '.foo' , '#foo > .bar.baz' returns '#foo > .bar'
+ // '.foo +bar' returns '.foo +' and likewise.
+ this._lastValidSearch = (query.match(/(.*)[\.#][^\.# ]{0,}$/) ||
+ query.match(/(.*[\s>+])[a-zA-Z][^\.# ]{0,}$/) ||
+ ["",""])[1];
+ return;
+
+ default:
+ return;
+ }
+
+ aEvent.preventDefault();
+ aEvent.stopPropagation();
+ if (this._searchResults.length > 0) {
+ this.callback(this._searchResults[this._searchIndex]);
+ }
+ },
+
+ /**
+ * Handles keypress and mouse click on the suggestions richlistbox.
+ */
+ _onListBoxKeypress: function SelectorSearch__onListBoxKeypress(aEvent) {
+ switch(aEvent.keyCode || aEvent.button) {
+ case aEvent.DOM_VK_ENTER:
+ case aEvent.DOM_VK_RETURN:
+ case aEvent.DOM_VK_TAB:
+ case 0: // left mouse button
+ aEvent.stopPropagation();
+ aEvent.preventDefault();
+ this.searchBox.value = this.searchPopup.selectedItem.label;
+ this.searchBox.focus();
+ this._onHTMLSearch();
+ break;
+
+ case aEvent.DOM_VK_UP:
+ if (this.searchPopup.selectedIndex == 0) {
+ this.searchPopup.selectedIndex = -1;
+ aEvent.stopPropagation();
+ aEvent.preventDefault();
+ this.searchBox.focus();
+ }
+ else {
+ let index = this.searchPopup.selectedIndex;
+ this.searchBox.value = this.searchPopup.getItemAtIndex(index - 1).label;
+ }
+ break;
+
+ case aEvent.DOM_VK_DOWN:
+ if (this.searchPopup.selectedIndex == this.searchPopup.itemCount - 1) {
+ this.searchPopup.selectedIndex = -1;
+ aEvent.stopPropagation();
+ aEvent.preventDefault();
+ this.searchBox.focus();
+ }
+ else {
+ let index = this.searchPopup.selectedIndex;
+ this.searchBox.value = this.searchPopup.getItemAtIndex(index + 1).label;
+ }
+ break;
+
+ case aEvent.DOM_VK_BACK_SPACE:
+ aEvent.stopPropagation();
+ aEvent.preventDefault();
+ this.searchBox.focus();
+ if (this.searchBox.selectionStart > 0) {
+ this.searchBox.value =
+ this.searchBox.value.substring(0, this.searchBox.selectionStart - 1);
+ }
+ this._lastToLastValidSearch = null;
+ let query = this.searchBox.value;
+ this._lastValidSearch = (query.match(/(.*)[\.#][^\.# ]{0,}$/) ||
+ query.match(/(.*[\s>+])[a-zA-Z][^\.# ]{0,}$/) ||
+ ["",""])[1];
+ this._onHTMLSearch();
+ break;
+ }
+ },
+
+
+ /**
+ * Populates the suggestions list and show the suggestion popup.
+ */
+ _showPopup: function SelectorSearch__showPopup(aList, aFirstPart) {
+ // Sort alphabetically in increaseing order.
+ aList = aList.sort();
+ // Sort based on count= in decreasing order.
+ aList = aList.sort(function([a1,a2], [b1,b2]) {
+ return a2 < b2;
+ });
+
+ let total = 0;
+ let query = this.searchBox.value;
+ let toLowerCase = false;
+ let items = [];
+ // In case of tagNames, change the case to small.
+ if (query.match(/.*[\.#][^\.#]{0,}$/) == null) {
+ toLowerCase = true;
+ }
+ for (let [value, count] of aList) {
+ // for cases like 'div ' or 'div >' or 'div+'
+ if (query.match(/[\s>+]$/)) {
+ value = query + value;
+ }
+ // for cases like 'div #a' or 'div .a' or 'div > d' and likewise
+ else if (query.match(/[\s>+][\.#a-zA-Z][^\s>+\.#]*$/)) {
+ let lastPart = query.match(/[\s>+][\.#a-zA-Z][^>\s+\.#]*$/)[0];
+ value = query.slice(0, -1 * lastPart.length + 1) + value;
+ }
+ // for cases like 'div.class' or '#foo.bar' and likewise
+ else if (query.match(/[a-zA-Z][#\.][^#\.\s+>]*$/)) {
+ let lastPart = query.match(/[a-zA-Z][#\.][^#\.\s>+]*$/)[0];
+ value = query.slice(0, -1 * lastPart.length + 1) + value;
+ }
+ let item = {
+ preLabel: query,
+ label: value,
+ count: count
+ };
+ if (toLowerCase) {
+ item.label = value.toLowerCase();
+ }
+ items.unshift(item);
+ if (++total > MAX_SUGGESTIONS - 1) {
+ break;
+ }
+ }
+ if (total > 0) {
+ this.searchPopup.setItems(items);
+ this.searchPopup.openPopup(this.searchBox);
+ }
+ else {
+ this.searchPopup.hidePopup();
+ }
+ },
+
+ /**
+ * Suggests classes,ids and tags based on the user input as user types in the
+ * searchbox.
+ */
+ showSuggestions: function SelectorSearch_showSuggestions() {
+ let query = this.searchBox.value;
+ if (this._lastValidSearch != "" &&
+ this._lastToLastValidSearch != this._lastValidSearch) {
+ this._searchSuggestions = {
+ ids: new Map(),
+ classes: new Map(),
+ tags: new Map(),
+ };
+
+ let nodes = [];
+ try {
+ nodes = this.doc.querySelectorAll(this._lastValidSearch);
+ } catch (ex) {}
+ for (let node of nodes) {
+ this._searchSuggestions.ids.set(node.id, 1);
+ this._searchSuggestions.tags
+ .set(node.tagName,
+ (this._searchSuggestions.tags.get(node.tagName) || 0) + 1);
+ for (let className of node.classList) {
+ this._searchSuggestions.classes
+ .set(className,
+ (this._searchSuggestions.classes.get(className) || 0) + 1);
+ }
+ }
+ this._lastToLastValidSearch = this._lastValidSearch;
+ }
+ else if (this._lastToLastValidSearch != this._lastValidSearch) {
+ this._searchSuggestions = {
+ ids: new Map(),
+ classes: new Map(),
+ tags: new Map(),
+ };
+
+ if (query.length == 0) {
+ return;
+ }
+
+ let nodes = null;
+ if (this.state == this.States.CLASS) {
+ nodes = this.doc.querySelectorAll("[class]");
+ for (let node of nodes) {
+ for (let className of node.classList) {
+ this._searchSuggestions.classes
+ .set(className,
+ (this._searchSuggestions.classes.get(className) || 0) + 1);
+ }
+ }
+ }
+ else if (this.state == this.States.ID) {
+ nodes = this.doc.querySelectorAll("[id]");
+ for (let node of nodes) {
+ this._searchSuggestions.ids.set(node.id, 1);
+ }
+ }
+ else if (this.state == this.States.TAG) {
+ nodes = this.doc.getElementsByTagName("*");
+ for (let node of nodes) {
+ this._searchSuggestions.tags
+ .set(node.tagName,
+ (this._searchSuggestions.tags.get(node.tagName) || 0) + 1);
+ }
+ }
+ else {
+ return;
+ }
+ this._lastToLastValidSearch = this._lastValidSearch;
+ }
+
+ // Filter the suggestions based on search box value.
+ let result = [];
+ let firstPart = "";
+ if (this.state == this.States.TAG) {
+ // gets the tag that is being completed. For ex. 'div.foo > s' returns 's',
+ // 'di' returns 'di' and likewise.
+ firstPart = (query.match(/[\s>+]?([a-zA-Z]*)$/) || ["",query])[1];
+ for (let [tag, count] of this._searchSuggestions.tags) {
+ if (tag.toLowerCase().startsWith(firstPart.toLowerCase())) {
+ result.push([tag, count]);
+ }
+ }
+ }
+ else if (this.state == this.States.CLASS) {
+ // gets the class that is being completed. For ex. '.foo.b' returns 'b'
+ firstPart = query.match(/\.([^\.]*)$/)[1];
+ for (let [className, count] of this._searchSuggestions.classes) {
+ if (className.startsWith(firstPart)) {
+ result.push(["." + className, count]);
+ }
+ }
+ firstPart = "." + firstPart;
+ }
+ else if (this.state == this.States.ID) {
+ // gets the id that is being completed. For ex. '.foo#b' returns 'b'
+ firstPart = query.match(/#([^#]*)$/)[1];
+ for (let [id, count] of this._searchSuggestions.ids) {
+ if (id.startsWith(firstPart)) {
+ result.push(["#" + id, 1]);
+ }
+ }
+ firstPart = "#" + firstPart;
+ }
+
+ this._showPopup(result, firstPart);
+ },
+};
|