/* 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 extensionControlled.js */ /* import-globals-from preferences.js */ var gSearchResultsPane = { listSearchTooltips: new Set(), listSearchMenuitemIndicators: new Set(), searchInput: null, // A map of DOM Elements to a string of keywords used in search // XXX: We should invalidate this cache on `intl:app-locales-changed` searchKeywords: new WeakMap(), 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("input", this); this.searchInput.addEventListener("command", this); window.addEventListener("DOMContentLoaded", () => { this.searchInput.focus(); }); // Initialize other panes in an idle callback. window.requestIdleCallback(() => this.initializeCategories()); } let helpUrl = Services.urlFormatter.formatURLPref("app.support.baseURL") + "preferences"; let helpContainer = document.getElementById("need-help"); helpContainer.querySelector("a").href = helpUrl; }, handleEvent(event) { // Ensure categories are initialized if idle callback didn't run sooo enough. this.initializeCategories(); this.searchFunction(event); }, /** * Check that the text content contains the query string. * * @param String content * the text content to be searched * @param String query * the query string * @returns boolean * true when the text content contains the query string else false */ queryMatchesContent(content, query) { if (!content || !query) { return false; } return content.toLowerCase().includes(query.toLowerCase()); }, 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 for (let index = 0; index < nodeSizes.length; index++) { let lengthNodes = nodeSizes[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.docShell; 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; }, /** * Shows or hides content according to search input * * @param String event * to search for filted query in */ async searchFunction(event) { let query = event.target.value.trim().toLowerCase(); if (this.query == query) { return; } let subQuery = this.query && query.includes(this.query); this.query = query; 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"); let noResultsEl = document.getElementById("no-results-message"); srHeader.hidden = !this.query; 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])")]; if (subQuery) { // Since the previous query is a subset of the current query, // there is no need to check elements that is hidden already. rootPreferencesChildren = rootPreferencesChildren.filter(el => !el.hidden); } // Attach the bindings for all children if they were not already visible. for (let child of rootPreferencesChildren) { if (child.hidden) { child.classList.add("visually-hidden"); child.hidden = false; } } let ts = performance.now(); let FRAME_THRESHOLD = 1000 / 60; // Showing or Hiding specific section depending on if words in query are found for (let child of rootPreferencesChildren) { if (performance.now() - ts > FRAME_THRESHOLD) { // Creating tooltips for all the instances found for (let anchorNode of this.listSearchTooltips) { this.createSearchTooltip(anchorNode, this.query); } ts = await new Promise(resolve => window.requestAnimationFrame(resolve)); if (query !== this.query) { return; } } if (!child.classList.contains("header") && !child.classList.contains("subcategory") && await this.searchWithinNode(child, this.query)) { child.classList.remove("visually-hidden"); // Show the preceding search-header if one exists. let groupbox = child.closest("groupbox"); let groupHeader = groupbox && groupbox.querySelector(".search-header"); if (groupHeader) { groupHeader.hidden = false; } resultsFound = true; } else { child.classList.add("visually-hidden"); } } noResultsEl.hidden = !!resultsFound; noResultsEl.setAttribute("query", this.query); // XXX: This is potentially racy in case where Fluent retranslates the // message and ereases the query within. // The feature is not yet supported, but we should fix for it before // we enable it. See bug 1446389 for details. let msgQueryElem = document.getElementById("sorry-message-query"); msgQueryElem.textContent = this.query; if (resultsFound) { // Creating tooltips for all the instances found for (let anchorNode of this.listSearchTooltips) { 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 { noResultsEl.hidden = true; document.getElementById("sorry-message-query").textContent = ""; // Going back to General when cleared gotoPref("paneGeneral"); // Hide some special second level headers in normal view for (let element of document.querySelectorAll(".search-header")) { element.hidden = true; } } window.dispatchEvent(new CustomEvent("PreferencesSearchCompleted", { detail: query })); }, /** * 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 */ async 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 anonymous content / label / description let nodeSizes = []; let allNodeText = ""; let runningSize = 0; let accessKeyTextNodes = []; let anons = document.getAnonymousNodes(nodeObject); if (anons) { for (let anon of anons) { accessKeyTextNodes.push(...this.textNodeDescendants(anon)); } } if (nodeObject.tagName == "label" || nodeObject.tagName == "description") { accessKeyTextNodes.push(...simpleTextNodes); } 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.queryMatchesContent(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" && nodeObject.tagName !== "radio" ? this.queryMatchesContent(nodeObject.getAttribute("value"), searchPhrase) : false; // Searching some elements, such as xul:button, buttons to open subdialogs // using l10n ids. let keywordsResult = nodeObject.hasAttribute("search-l10n-ids") && await this.matchesSearchL10nIDs(nodeObject, searchPhrase); if (!keywordsResult) { // Searching some elements, such as xul:button, buttons to open subdialogs // using searchkeywords attribute. keywordsResult = !keywordsResult && nodeObject.hasAttribute("searchkeywords") && this.queryMatchesContent(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 element // except the "historyPane" element. if (nodeObject.tagName == "deck" && nodeObject.id != "historyPane") { let index = nodeObject.selectedIndex; if (index != -1) { let result = await this.searchChildNodeIfVisible(nodeObject, index, searchPhrase); matchesFound = matchesFound || result; } } else { for (let i = 0; i < nodeObject.childNodes.length; i++) { let result = await 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 */ async searchChildNodeIfVisible(nodeObject, index, searchPhrase) { let result = false; if (!nodeObject.childNodes[index].hidden && nodeObject.getAttribute("data-hidden-from-search") !== "true") { result = await this.searchWithinNode(nodeObject.childNodes[index], searchPhrase); // Creating tooltips for menulist element if (result && nodeObject.tagName === "menulist") { this.listSearchTooltips.add(nodeObject); } } return result; }, /** * Search for a phrase in l10n messages associated with the element. * * @param Node nodeObject * The parent DOM Element * @param String searchPhrase * @returns boolean * true when the text content contains the query string else false */ async matchesSearchL10nIDs(nodeObject, searchPhrase) { if (!this.searchKeywords.has(nodeObject)) { // The `search-l10n-ids` attribute is a comma-separated list of // l10n ids. It may also uses a dot notation to specify an attribute // of the message to be used. // // Example: "containers-add-button.label, user-context-personal" // // The result is an array of arrays of l10n ids and optionally attribute names. // // Example: [["containers-add-button", "label"], ["user-context-personal"]] const refs = nodeObject.getAttribute("search-l10n-ids") .split(",") .map(s => s.trim().split(".")).filter(s => s[0].length > 0); const messages = await document.l10n.formatMessages( refs.map(ref => ({ id: ref[0] }))); // Map the localized messages taking value or a selected attribute and // building a string of concatenated translated strings out of it. let keywords = messages.map((msg, i) => { let [refId, refAttr] = refs[i]; if (!msg) { console.error(`Missing search l10n id "${refId}"`); return null; } if (refAttr) { let attr = msg.attributes && msg.attributes.find(a => a.name === refAttr); if (!attr) { console.error(`Missing search l10n id "${refId}.${refAttr}"`); return null; } if (attr.value === "") { console.error(`Empty value added to search-l10n-ids "${refId}.${refAttr}"`); } return attr.value; } if (msg.value === "") { console.error(`Empty value added to search-l10n-ids "${refId}"`); } return msg.value; }).filter(keyword => keyword !== null).join(" "); this.searchKeywords.set(nodeObject, keywords); return this.queryMatchesContent(keywords, searchPhrase); } return this.queryMatchesContent(this.searchKeywords.get(nodeObject), searchPhrase); }, /** * 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) { if (anchorNode.tooltipNode) { return; } let searchTooltip = anchorNode.ownerDocument.createXULElement("span"); let searchTooltipText = anchorNode.ownerDocument.createXULElement("span"); searchTooltip.className = "search-tooltip"; searchTooltipText.textContent = query; searchTooltip.appendChild(searchTooltipText); // 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. */ removeAllSearchTooltips() { for (let anchorNode of this.listSearchTooltips) { anchorNode.parentElement.classList.remove("search-tooltip-parent"); if (anchorNode.tooltipNode) { anchorNode.tooltipNode.remove(); } anchorNode.tooltipNode = null; } this.listSearchTooltips.clear(); }, /** * Remove all indicators on menuitem. */ removeAllSearchMenuitemIndicators() { for (let node of this.listSearchMenuitemIndicators) { node.removeAttribute("indicator"); } this.listSearchMenuitemIndicators.clear(); }, };