summaryrefslogtreecommitdiffstats
path: root/comm/mail/components/preferences/findInPage.js
diff options
context:
space:
mode:
Diffstat (limited to 'comm/mail/components/preferences/findInPage.js')
-rw-r--r--comm/mail/components/preferences/findInPage.js641
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();
+ },
+};