gecko/browser/devtools/inspector/selector-search.js
Mihai Sucan b255378927 Bug 877262 - Part 2: move the Web Console files over to the new jetpack-based loader; r=dcamp
--HG--
rename : browser/devtools/shared/AutocompletePopup.jsm => browser/devtools/shared/autocomplete-popup.js
rename : browser/devtools/webconsole/HUDService.jsm => browser/devtools/webconsole/hudservice.js
rename : browser/devtools/webconsole/NetworkPanel.jsm => browser/devtools/webconsole/network-panel.js
rename : browser/devtools/webconsole/WebConsolePanel.jsm => browser/devtools/webconsole/panel.js
rename : toolkit/devtools/webconsole/WebConsoleClient.jsm => toolkit/devtools/webconsole/client.js
rename : toolkit/devtools/webconsole/NetworkHelper.jsm => toolkit/devtools/webconsole/network-helper.js
rename : toolkit/devtools/webconsole/WebConsoleUtils.jsm => toolkit/devtools/webconsole/utils.js
2013-08-03 13:29:48 +03:00

582 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 EventEmitter = require("devtools/shared/event-emitter");
const promise = require("sdk/core/promise");
loader.lazyGetter(this, "AutocompletePopup", () => require("devtools/shared/autocomplete-popup").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 InspectorPanel aInspector
* The InspectorPanel whose `walker` attribute should be used for
* document traversal.
* @param nsIDOMDocument aContentDocument
* The content document which inspector is attached to, or null if
* a remote document.
* @param nsiInputElement aInputNode
* The input element to which the panel will be attached and from where
* search input will be taken.
*/
function SelectorSearch(aInspector, aContentDocument, aInputNode) {
this.inspector = aInspector;
this.doc = aContentDocument;
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);
// For testing, we need to be able to wait for the most recent node request
// to finish. Tests can watch this promise for that.
this._lastQuery = promise.resolve(null);
EventEmitter.decorate(this);
}
exports.SelectorSearch = SelectorSearch;
SelectorSearch.prototype = {
get walker() this.inspector.walker,
// 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;
EventEmitter.decorate(this);
},
_selectResult: function(index) {
return this._searchResults.item(index).then(node => {
this.emit("node-selected", node);
});
},
/**
* 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._searchResults = [];
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);
let queryList = null;
this._lastQuery = this.walker.querySelectorAll(this.walker.rootNode, query).then(list => {
return list;
}, (err) => {
// Failures are ok here, just use a null item list;
return null;
}).then(queryList => {
// Value has changed since we started this request, we're done.
if (query != this.searchBox.value) {
if (queryList) {
queryList.release();
}
return promise.reject(null);
}
this._searchResults = queryList || [];
if (this._searchResults && 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");
return this._selectResult(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();
}
return undefined;
});
},
/**
* 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._searchResults) {
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 && this._searchResults.length > 0) {
this._lastQuery = this._selectResult(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() {
if (!this.walker.isLocal()) {
return;
}
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);
},
};