diff -r 000000000000 -r 6474c204b198 browser/devtools/inspector/selector-search.js --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/browser/devtools/inspector/selector-search.js Wed Dec 31 06:09:35 2014 +0100 @@ -0,0 +1,492 @@ +/* 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 promise = require("devtools/toolkit/deprecated-sync-thenables"); + +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 nsiInputElement aInputNode + * The input element to which the panel will be attached and from where + * search input will be taken. + */ +function SelectorSearch(aInspector, aInputNode) { + this.inspector = aInspector; + 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", + autoSelect: true, + position: "before_start", + direction: "ltr", + theme: "auto", + 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); +} + +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() { + // 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.panelDoc = null; + this._searchResults = null; + this._searchSuggestions = null; + }, + + _selectResult: function(index) { + return this._searchResults.item(index).then(node => { + this.inspector.selection.setNodeFront(node, "selectorsearch"); + }); + }, + + /** + * 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() { + 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(); + } + this.searchBox.classList.remove("devtools-no-search-result"); + + return this._selectResult(0); + } + return this._selectResult(0).then(() => { + this.searchBox.classList.remove("devtools-no-search-result"); + }).then(() => this.showSuggestions()); + } + 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"); + return this.showSuggestions(); + }); + }, + + /** + * Handles keypresses inside the input box. + */ + _onSearchKeypress: function(aEvent) { + let query = this.searchBox.value; + switch(aEvent.keyCode) { + 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(aEvent) { + switch(aEvent.keyCode || aEvent.button) { + 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(aList, aFirstPart) { + 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() { + let query = this.searchBox.value; + 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]; + query = query.slice(0, query.length - firstPart.length); + } + else if (this.state == this.States.CLASS) { + // gets the class that is being completed. For ex. '.foo.b' returns 'b' + firstPart = query.match(/\.([^\.]*)$/)[1]; + query = query.slice(0, query.length - firstPart.length - 1); + } + else if (this.state == this.States.ID) { + // gets the id that is being completed. For ex. '.foo#b' returns 'b' + firstPart = query.match(/#([^#]*)$/)[1]; + query = query.slice(0, query.length - firstPart.length - 1); + } + // TODO: implement some caching so that over the wire request is not made + // everytime. + if (/[\s+>~]$/.test(query)) { + query += "*"; + } + this._currentSuggesting = query; + return this.walker.getSuggestionsForQuery(query, firstPart, this.state).then(result => { + if (this._currentSuggesting != result.query) { + // This means that this response is for a previous request and the user + // as since typed something extra leading to a new request. + return; + } + this._lastToLastValidSearch = this._lastValidSearch; + if (this.state == this.States.CLASS) { + firstPart = "." + firstPart; + } + else if (this.state == this.States.ID) { + firstPart = "#" + firstPart; + } + this._showPopup(result.suggestions, firstPart); + }); + } +};