This shuffled what bug 1374852 did a little bit; instead of initialize all panes when the page loads, the call will happen inside an idle callback. An the event listener is added to ensure that even if idle callback doesn't run soon enough, the function is run already when the user starts search. MozReview-Commit-ID: LU4Nb8lql36
454 lines
16 KiB
JavaScript
454 lines
16 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/. */
|
|
|
|
/* import-globals-from preferences.js */
|
|
|
|
var gSearchResultsPane = {
|
|
listSearchTooltips: new Set(),
|
|
listSearchMenuitemIndicators: new Set(),
|
|
searchInput: null,
|
|
inited: false,
|
|
|
|
init() {
|
|
if (this.inited) {
|
|
return;
|
|
}
|
|
this.inited = true;
|
|
this.searchInput = document.getElementById("searchInput");
|
|
this.searchInput.hidden = !Services.prefs.getBoolPref("browser.preferences.search");
|
|
if (!this.searchInput.hidden) {
|
|
this.searchInput.addEventListener("command", this);
|
|
window.addEventListener("DOMContentLoaded", () => {
|
|
this.searchInput.focus();
|
|
});
|
|
// Initialize other panes in an idle callback.
|
|
window.requestIdleCallback(() => this.initializeCategories());
|
|
}
|
|
let strings = this.strings;
|
|
this.searchInput.placeholder = AppConstants.platform == "win" ?
|
|
strings.getString("searchInput.labelWin") :
|
|
strings.getString("searchInput.labelUnix");
|
|
},
|
|
|
|
handleEvent(event) {
|
|
// Ensure categories are initialized if idle callback didn't run sooo enough.
|
|
this.initializeCategories();
|
|
this.searchFunction(event);
|
|
},
|
|
|
|
/**
|
|
* Check that the passed string matches the filter arguments.
|
|
*
|
|
* @param String str
|
|
* to search for filter words in.
|
|
* @param String filter
|
|
* is a string containing all of the words to filter on.
|
|
* @returns boolean
|
|
* true when match in string else false
|
|
*/
|
|
stringMatchesFilters(str, filter) {
|
|
if (!filter || !str) {
|
|
return false;
|
|
}
|
|
let searchStr = str.toLowerCase();
|
|
let filterStrings = filter.toLowerCase().split(/\s+/);
|
|
return !filterStrings.some(f => searchStr.indexOf(f) == -1);
|
|
},
|
|
|
|
categoriesInitialized: false,
|
|
|
|
/**
|
|
* Will attempt to initialize all uninitialized categories
|
|
*/
|
|
initializeCategories() {
|
|
// Initializing all the JS for all the tabs
|
|
if (!this.categoriesInitialized) {
|
|
this.categoriesInitialized = true;
|
|
// Each element of gCategoryInits is a name
|
|
for (let [/* name */, category] of gCategoryInits) {
|
|
if (!category.inited) {
|
|
category.init();
|
|
}
|
|
}
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Finds and returns text nodes within node and all descendants
|
|
* Iterates through all the sibilings of the node object and adds the sibilings
|
|
* to an array if sibiling is a TEXT_NODE else checks the text nodes with in current node
|
|
* Source - http://stackoverflow.com/questions/10730309/find-all-text-nodes-in-html-page
|
|
*
|
|
* @param Node nodeObject
|
|
* DOM element
|
|
* @returns array of text nodes
|
|
*/
|
|
textNodeDescendants(node) {
|
|
if (!node) {
|
|
return [];
|
|
}
|
|
let all = [];
|
|
for (node = node.firstChild; node; node = node.nextSibling) {
|
|
if (node.nodeType === node.TEXT_NODE) {
|
|
all.push(node);
|
|
} else {
|
|
all = all.concat(this.textNodeDescendants(node));
|
|
}
|
|
}
|
|
return all;
|
|
},
|
|
|
|
/**
|
|
* This function is used to find words contained within the text nodes.
|
|
* We pass in the textNodes because they contain the text to be highlighted.
|
|
* We pass in the nodeSizes to tell exactly where highlighting need be done.
|
|
* When creating the range for highlighting, if the nodes are section is split
|
|
* by an access key, it is important to have the size of each of the nodes summed.
|
|
* @param Array textNodes
|
|
* List of DOM elements
|
|
* @param Array nodeSizes
|
|
* Running size of text nodes. This will contain the same number of elements as textNodes.
|
|
* The first element is the size of first textNode element.
|
|
* For any nodes after, they will contain the summation of the nodes thus far in the array.
|
|
* Example:
|
|
* textNodes = [[This is ], [a], [n example]]
|
|
* nodeSizes = [[8], [9], [18]]
|
|
* This is used to determine the offset when highlighting
|
|
* @param String textSearch
|
|
* Concatination of textNodes's text content
|
|
* Example:
|
|
* textNodes = [[This is ], [a], [n example]]
|
|
* nodeSizes = "This is an example"
|
|
* This is used when executing the regular expression
|
|
* @param String searchPhrase
|
|
* word or words to search for
|
|
* @returns boolean
|
|
* Returns true when atleast one instance of search phrase is found, otherwise false
|
|
*/
|
|
highlightMatches(textNodes, nodeSizes, textSearch, searchPhrase) {
|
|
if (!searchPhrase) {
|
|
return false;
|
|
}
|
|
|
|
let indices = [];
|
|
let i = -1;
|
|
while ((i = textSearch.indexOf(searchPhrase, i + 1)) >= 0) {
|
|
indices.push(i);
|
|
}
|
|
|
|
// Looping through each spot the searchPhrase is found in the concatenated string
|
|
for (let startValue of indices) {
|
|
let endValue = startValue + searchPhrase.length;
|
|
let startNode = null;
|
|
let endNode = null;
|
|
let nodeStartIndex = null;
|
|
|
|
// Determining the start and end node to highlight from
|
|
nodeSizes.forEach(function(lengthNodes, index) {
|
|
// Determining the start node
|
|
if (!startNode && lengthNodes >= startValue) {
|
|
startNode = textNodes[index];
|
|
nodeStartIndex = index;
|
|
// Calculating the offset when found query is not in the first node
|
|
if (index > 0) {
|
|
startValue -= nodeSizes[index - 1];
|
|
}
|
|
}
|
|
// Determining the end node
|
|
if (!endNode && lengthNodes >= endValue) {
|
|
endNode = textNodes[index];
|
|
// Calculating the offset when endNode is different from startNode
|
|
// or when endNode is not the first node
|
|
if (index != nodeStartIndex || index > 0 ) {
|
|
endValue -= nodeSizes[index - 1];
|
|
}
|
|
}
|
|
});
|
|
let range = document.createRange();
|
|
range.setStart(startNode, startValue);
|
|
range.setEnd(endNode, endValue);
|
|
this.getFindSelection(startNode.ownerGlobal).addRange(range);
|
|
}
|
|
|
|
return indices.length > 0;
|
|
},
|
|
|
|
/**
|
|
* Get the selection instance from given window
|
|
*
|
|
* @param Object win
|
|
* The window object points to frame's window
|
|
*/
|
|
getFindSelection(win) {
|
|
// Yuck. See bug 138068.
|
|
let docShell = win.QueryInterface(Ci.nsIInterfaceRequestor)
|
|
.getInterface(Ci.nsIWebNavigation)
|
|
.QueryInterface(Ci.nsIDocShell);
|
|
|
|
let controller = docShell.QueryInterface(Ci.nsIInterfaceRequestor)
|
|
.getInterface(Ci.nsISelectionDisplay)
|
|
.QueryInterface(Ci.nsISelectionController);
|
|
|
|
let selection = controller.getSelection(Ci.nsISelectionController.SELECTION_FIND);
|
|
selection.setColors("currentColor", "#ffe900", "currentColor", "#003eaa");
|
|
|
|
return selection;
|
|
},
|
|
|
|
get strings() {
|
|
delete this.strings;
|
|
return this.strings = document.getElementById("searchResultBundle");
|
|
},
|
|
|
|
/**
|
|
* Shows or hides content according to search input
|
|
*
|
|
* @param String event
|
|
* to search for filted query in
|
|
*/
|
|
searchFunction(event) {
|
|
this.query = event.target.value.trim().toLowerCase();
|
|
this.getFindSelection(window).removeAllRanges();
|
|
this.removeAllSearchTooltips();
|
|
this.removeAllSearchMenuitemIndicators();
|
|
|
|
// Clear telemetry request if user types very frequently.
|
|
if (this.telemetryTimer) {
|
|
clearTimeout(this.telemetryTimer);
|
|
}
|
|
|
|
let srHeader = document.getElementById("header-searchResults");
|
|
|
|
if (this.query) {
|
|
// Showing the Search Results Tag
|
|
gotoPref("paneSearchResults");
|
|
|
|
let resultsFound = false;
|
|
|
|
// Building the range for highlighted areas
|
|
let rootPreferencesChildren = document
|
|
.querySelectorAll("#mainPrefPane > *:not([data-hidden-from-search])");
|
|
|
|
// Showing all the children to bind JS, Access Keys, etc
|
|
for (let i = 0; i < rootPreferencesChildren.length; i++) {
|
|
rootPreferencesChildren[i].hidden = false;
|
|
}
|
|
|
|
// Showing or Hiding specific section depending on if words in query are found
|
|
for (let i = 0; i < rootPreferencesChildren.length; i++) {
|
|
if (!rootPreferencesChildren[i].classList.contains("header") &&
|
|
!rootPreferencesChildren[i].classList.contains("subcategory") &&
|
|
!rootPreferencesChildren[i].classList.contains("no-results-message") &&
|
|
this.searchWithinNode(rootPreferencesChildren[i], this.query)) {
|
|
rootPreferencesChildren[i].hidden = false;
|
|
resultsFound = true;
|
|
} else {
|
|
rootPreferencesChildren[i].hidden = true;
|
|
}
|
|
}
|
|
// It hides Search Results header so turning it on
|
|
srHeader.hidden = false;
|
|
|
|
if (!resultsFound) {
|
|
let noResultsEl = document.querySelector(".no-results-message");
|
|
noResultsEl.hidden = false;
|
|
|
|
let strings = this.strings;
|
|
|
|
document.getElementById("sorry-message").textContent = AppConstants.platform == "win" ?
|
|
strings.getFormattedString("searchResults.sorryMessageWin", [this.query]) :
|
|
strings.getFormattedString("searchResults.sorryMessageUnix", [this.query]);
|
|
let helpUrl = Services.urlFormatter.formatURLPref("app.support.baseURL") + "preferences";
|
|
let brandName = document.getElementById("bundleBrand").getString("brandShortName");
|
|
// eslint-disable-next-line no-unsanitized/property
|
|
document.getElementById("need-help").innerHTML =
|
|
strings.getFormattedString("searchResults.needHelp2", [helpUrl, brandName]);
|
|
} else {
|
|
// Creating tooltips for all the instances found
|
|
this.listSearchTooltips.forEach((anchorNode) => this.createSearchTooltip(anchorNode, this.query));
|
|
|
|
// Implant search telemetry probe after user stops typing for a while
|
|
if (this.query.length >= 2) {
|
|
this.telemetryTimer = setTimeout(() => {
|
|
Services.telemetry.keyedScalarAdd("preferences.search_query", this.query, 1);
|
|
}, 1000);
|
|
}
|
|
}
|
|
} else {
|
|
document.getElementById("sorry-message").textContent = "";
|
|
// Going back to General when cleared
|
|
gotoPref("paneGeneral");
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Finding leaf nodes and checking their content for words to search,
|
|
* It is a recursive function
|
|
*
|
|
* @param Node nodeObject
|
|
* DOM Element
|
|
* @param String searchPhrase
|
|
* @returns boolean
|
|
* Returns true when found in at least one childNode, false otherwise
|
|
*/
|
|
searchWithinNode(nodeObject, searchPhrase) {
|
|
let matchesFound = false;
|
|
if (nodeObject.childElementCount == 0 ||
|
|
nodeObject.tagName == "label" ||
|
|
nodeObject.tagName == "description" ||
|
|
nodeObject.tagName == "menulist") {
|
|
let simpleTextNodes = this.textNodeDescendants(nodeObject);
|
|
|
|
for (let node of simpleTextNodes) {
|
|
let result = this.highlightMatches([node], [node.length], node.textContent.toLowerCase(), searchPhrase);
|
|
matchesFound = matchesFound || result;
|
|
}
|
|
|
|
// Collecting data from boxObject / label / description
|
|
let nodeSizes = [];
|
|
let allNodeText = "";
|
|
let runningSize = 0;
|
|
let accessKeyTextNodes = this.textNodeDescendants(nodeObject.boxObject);
|
|
|
|
if (nodeObject.tagName == "label" || nodeObject.tagName == "description") {
|
|
accessKeyTextNodes.push(...this.textNodeDescendants(nodeObject));
|
|
}
|
|
|
|
for (let node of accessKeyTextNodes) {
|
|
runningSize += node.textContent.length;
|
|
allNodeText += node.textContent;
|
|
nodeSizes.push(runningSize);
|
|
}
|
|
|
|
// Access key are presented
|
|
let complexTextNodesResult = this.highlightMatches(accessKeyTextNodes, nodeSizes, allNodeText.toLowerCase(), searchPhrase);
|
|
|
|
// Searching some elements, such as xul:button, have a 'label' attribute that contains the user-visible text.
|
|
let labelResult = this.stringMatchesFilters(nodeObject.getAttribute("label"), searchPhrase);
|
|
|
|
// Searching some elements, such as xul:label, store their user-visible text in a "value" attribute.
|
|
// Value will be skipped for menuitem since value in menuitem could represent index number to distinct each item.
|
|
let valueResult = nodeObject.tagName !== "menuitem" ?
|
|
this.stringMatchesFilters(nodeObject.getAttribute("value"), searchPhrase) : false;
|
|
|
|
// Searching some elements, such as xul:button, buttons to open subdialogs.
|
|
let keywordsResult = this.stringMatchesFilters(nodeObject.getAttribute("searchkeywords"), searchPhrase);
|
|
|
|
// Creating tooltips for buttons
|
|
if (keywordsResult && (nodeObject.tagName === "button" || nodeObject.tagName == "menulist")) {
|
|
this.listSearchTooltips.add(nodeObject);
|
|
}
|
|
|
|
if (keywordsResult && nodeObject.tagName === "menuitem") {
|
|
nodeObject.setAttribute("indicator", "true");
|
|
this.listSearchMenuitemIndicators.add(nodeObject);
|
|
let menulist = nodeObject.closest("menulist");
|
|
|
|
menulist.setAttribute("indicator", "true");
|
|
this.listSearchMenuitemIndicators.add(menulist);
|
|
}
|
|
|
|
if ((nodeObject.tagName == "button" ||
|
|
nodeObject.tagName == "menulist" ||
|
|
nodeObject.tagName == "menuitem") &&
|
|
(labelResult || valueResult || keywordsResult)) {
|
|
nodeObject.setAttribute("highlightable", "true");
|
|
}
|
|
|
|
matchesFound = matchesFound || complexTextNodesResult || labelResult || valueResult || keywordsResult;
|
|
}
|
|
|
|
// Should not search unselected child nodes of a <xul:deck> element
|
|
// except the "historyPane" <xul:deck> element.
|
|
if (nodeObject.tagName == "deck" && nodeObject.id != "historyPane") {
|
|
let index = nodeObject.selectedIndex;
|
|
if (index != -1) {
|
|
let result = this.searchChildNodeIfVisible(nodeObject, index, searchPhrase);
|
|
matchesFound = matchesFound || result;
|
|
}
|
|
} else {
|
|
for (let i = 0; i < nodeObject.childNodes.length; i++) {
|
|
let result = this.searchChildNodeIfVisible(nodeObject, i, searchPhrase);
|
|
matchesFound = matchesFound || result;
|
|
}
|
|
}
|
|
return matchesFound;
|
|
},
|
|
|
|
/**
|
|
* Search for a phrase within a child node if it is visible.
|
|
*
|
|
* @param Node nodeObject
|
|
* The parent DOM Element
|
|
* @param Number index
|
|
* The index for the childNode
|
|
* @param String searchPhrase
|
|
* @returns boolean
|
|
* Returns true when found the specific childNode, false otherwise
|
|
*/
|
|
searchChildNodeIfVisible(nodeObject, index, searchPhrase) {
|
|
let result = false;
|
|
if (!nodeObject.childNodes[index].hidden && nodeObject.getAttribute("data-hidden-from-search") !== "true") {
|
|
result = this.searchWithinNode(nodeObject.childNodes[index], searchPhrase);
|
|
// Creating tooltips for menulist element
|
|
if (result && nodeObject.tagName === "menulist") {
|
|
this.listSearchTooltips.add(nodeObject);
|
|
}
|
|
}
|
|
return result;
|
|
},
|
|
|
|
/**
|
|
* Inserting a div structure infront of the DOM element matched textContent.
|
|
* Then calculation the offsets to position the tooltip in the correct place.
|
|
*
|
|
* @param Node anchorNode
|
|
* DOM Element
|
|
* @param String query
|
|
* Word or words that are being searched for
|
|
*/
|
|
createSearchTooltip(anchorNode, query) {
|
|
let searchTooltip = anchorNode.ownerDocument.createElement("span");
|
|
searchTooltip.setAttribute("class", "search-tooltip");
|
|
searchTooltip.textContent = query;
|
|
|
|
// Set tooltipNode property to track corresponded tooltip node.
|
|
anchorNode.tooltipNode = searchTooltip;
|
|
anchorNode.parentElement.classList.add("search-tooltip-parent");
|
|
anchorNode.parentElement.appendChild(searchTooltip);
|
|
|
|
this.calculateTooltipPosition(anchorNode);
|
|
},
|
|
|
|
calculateTooltipPosition(anchorNode) {
|
|
let searchTooltip = anchorNode.tooltipNode;
|
|
// In order to get the up-to-date position of each of the nodes that we're
|
|
// putting tooltips on, we have to flush layout intentionally, and that
|
|
// this is the result of a XUL limitation (bug 1363730).
|
|
let tooltipRect = searchTooltip.getBoundingClientRect();
|
|
searchTooltip.style.setProperty("left", `calc(50% - ${(tooltipRect.width / 2)}px)`);
|
|
},
|
|
|
|
/**
|
|
* Remove all search tooltips that were created.
|
|
*/
|
|
removeAllSearchTooltips() {
|
|
let searchTooltips = Array.from(document.querySelectorAll(".search-tooltip"));
|
|
for (let searchTooltip of searchTooltips) {
|
|
searchTooltip.parentElement.classList.remove("search-tooltip-parent");
|
|
searchTooltip.remove();
|
|
}
|
|
this.listSearchTooltips.forEach((anchorNode) => anchorNode.tooltipNode.remove());
|
|
this.listSearchTooltips.clear();
|
|
},
|
|
|
|
/**
|
|
* Remove all indicators on menuitem.
|
|
*/
|
|
removeAllSearchMenuitemIndicators() {
|
|
this.listSearchMenuitemIndicators.forEach((node) => node.removeAttribute("indicator"));
|
|
this.listSearchMenuitemIndicators.clear();
|
|
}
|
|
}
|