diff options
Diffstat (limited to 'comm/mail/components/preferences/findInPage.js')
-rw-r--r-- | comm/mail/components/preferences/findInPage.js | 641 |
1 files changed, 641 insertions, 0 deletions
diff --git a/comm/mail/components/preferences/findInPage.js b/comm/mail/components/preferences/findInPage.js new file mode 100644 index 0000000000..c69e8b50b6 --- /dev/null +++ b/comm/mail/components/preferences/findInPage.js @@ -0,0 +1,641 @@ +/* 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 */ + +// A tweak to the standard <button> CE to use textContent on the <label> +// inside the button, which allows the text to be highlighted when the user +// is searching. + +const MozButton = customElements.get("button"); +class HighlightableButton extends MozButton { + static get inheritedAttributes() { + return Object.assign({}, super.inheritedAttributes, { + ".button-text": "text=label,accesskey,crop", + }); + } +} +customElements.define("highlightable-button", HighlightableButton, { + extends: "button", +}); + +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; + }, + + async handleEvent(event) { + // Ensure categories are initialized if idle callback didn't run soon enough. + await 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. + */ + async 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 ( + (name != "paneCalendar" && !category.inited) || + (calendarDeactivator.isCalendarActivated && !category.inited) + ) { + await category.init(); + } + } + let lastSelected = Services.xulStore.getValue( + "about:preferences", + "paneDeck", + "lastSelected" + ); + search(lastSelected, "data-category"); + } + }, + + /** + * 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 sibling 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} node DOM element. + * + * @returns {Node[]} 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 {Node[]} textNodes List of DOM elements. + * @param {Node[]} 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 Concatenation 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.dom-mutation-list. + 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; + }, + + /** + * 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 {object} 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(); + + let srHeader = document.getElementById("header-searchResults"); + let noResultsEl = document.getElementById("no-results-message"); + if (this.query) { + // Showing the Search Results Tag. + await gotoPref("paneSearchResults"); + srHeader.hidden = false; + + let resultsFound = false; + + // Building the range for highlighted areas. + let rootPreferencesChildren = [ + ...document.querySelectorAll( + "#paneDeck > *:not([data-hidden-from-search],script,stringbundle,commandset,keyset,linkset)" + ), + ]; + + 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 = 10; + + // 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); + } + } + } else { + noResultsEl.hidden = true; + document.getElementById("sorry-message-query").textContent = ""; + // Going back to General when cleared. + await gotoPref("paneGeneral"); + srHeader.hidden = true; + + // 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 == "button" || + nodeObject.tagName == "label" || + nodeObject.tagName == "description" || + nodeObject.tagName == "menulist" || + nodeObject.tagName == "menuitem" + ) { + 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 = []; + + 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 == "menulist" || + nodeObject.tagName == "menuitem") && + (labelResult || valueResult || keywordsResult) + ) { + nodeObject.setAttribute("highlightable", "true"); + } + + matchesFound = + matchesFound || + complexTextNodesResult || + labelResult || + valueResult || + keywordsResult; + } + + 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); + + 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.createElement("span"); + let searchTooltipText = anchorNode.ownerDocument.createElement("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(); + }, +}; |