Bug 1944627 - update sidebar button checked state for non-revamped sidebar cases...
[gecko.git] / browser / components / preferences / findInPage.js
blob0bac5e92220c07335a43a80776c563ade6fc4810
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this file,
3 * You can obtain one at http://mozilla.org/MPL/2.0/. */
5 /* import-globals-from extensionControlled.js */
6 /* import-globals-from preferences.js */
8 // A tweak to the standard <button> CE to use textContent on the <label>
9 // inside the button, which allows the text to be highlighted when the user
10 // is searching.
12 const MozButton = customElements.get("button");
13 class HighlightableButton extends MozButton {
14 static get inheritedAttributes() {
15 return Object.assign({}, super.inheritedAttributes, {
16 ".button-text": "text=label,accesskey,crop",
17 });
20 customElements.define("highlightable-button", HighlightableButton, {
21 extends: "button",
22 });
24 var gSearchResultsPane = {
25 listSearchTooltips: new Set(),
26 listSearchMenuitemIndicators: new Set(),
27 searchInput: null,
28 // A map of DOM Elements to a string of keywords used in search
29 // XXX: We should invalidate this cache on `intl:app-locales-changed`
30 searchKeywords: new WeakMap(),
31 inited: false,
33 // A (node -> boolean) map of subitems to be made visible or hidden.
34 subItems: new Map(),
36 searchResultsHighlighted: false,
38 searchableNodes: new Set([
39 "button",
40 "label",
41 "description",
42 "menulist",
43 "menuitem",
44 "checkbox",
45 ]),
47 init() {
48 if (this.inited) {
49 return;
51 this.inited = true;
52 this.searchInput = document.getElementById("searchInput");
54 window.addEventListener("resize", () => {
55 this._recomputeTooltipPositions();
56 });
58 if (!this.searchInput.hidden) {
59 this.searchInput.addEventListener("input", this);
60 this.searchInput.addEventListener("command", this);
61 window.addEventListener("DOMContentLoaded", () => {
62 this.searchInput.focus();
63 // Initialize other panes in an idle callback.
64 window.requestIdleCallback(() => this.initializeCategories());
65 });
67 ensureScrollPadding();
70 async handleEvent(event) {
71 // Ensure categories are initialized if idle callback didn't run sooo enough.
72 await this.initializeCategories();
73 this.searchFunction(event);
76 /**
77 * This stops the search input from moving, when typing in it
78 * changes which items in the prefs are visible.
80 fixInputPosition() {
81 let innerContainer = document.querySelector(".sticky-inner-container");
82 let width =
83 window.windowUtils.getBoundsWithoutFlushing(innerContainer).width;
84 innerContainer.style.maxWidth = width + "px";
87 /**
88 * Check that the text content contains the query string.
90 * @param String content
91 * the text content to be searched
92 * @param String query
93 * the query string
94 * @returns boolean
95 * true when the text content contains the query string else false
97 queryMatchesContent(content, query) {
98 if (!content || !query) {
99 return false;
101 return content.toLowerCase().includes(query.toLowerCase());
104 categoriesInitialized: false,
107 * Will attempt to initialize all uninitialized categories
109 async initializeCategories() {
110 // Initializing all the JS for all the tabs
111 if (!this.categoriesInitialized) {
112 this.categoriesInitialized = true;
113 // Each element of gCategoryInits is a name
114 for (let category of gCategoryInits.values()) {
115 category.init();
117 if (document.hasPendingL10nMutations) {
118 await new Promise(r =>
119 document.addEventListener("L10nMutationsFinished", r, { once: true })
126 * Finds and returns text nodes within node and all descendants.
127 * Iterates through all the siblings of the node object and adds each sibling to an
128 * array if it's a TEXT_NODE, and otherwise recurses to check text nodes within it.
129 * Source - http://stackoverflow.com/questions/10730309/find-all-text-nodes-in-html-page
131 * @param Node nodeObject
132 * DOM element
133 * @returns array of text nodes
135 textNodeDescendants(node) {
136 if (!node) {
137 return [];
139 let all = [];
140 for (node = node.firstChild; node; node = node.nextSibling) {
141 if (node.nodeType === node.TEXT_NODE) {
142 all.push(node);
143 } else {
144 all = all.concat(this.textNodeDescendants(node));
147 return all;
151 * This function is used to find words contained within the text nodes.
152 * We pass in the textNodes because they contain the text to be highlighted.
153 * We pass in the nodeSizes to tell exactly where highlighting need be done.
154 * When creating the range for highlighting, if the nodes are section is split
155 * by an access key, it is important to have the size of each of the nodes summed.
156 * @param Array textNodes
157 * List of DOM elements
158 * @param Array nodeSizes
159 * Running size of text nodes. This will contain the same number of elements as textNodes.
160 * The first element is the size of first textNode element.
161 * For any nodes after, they will contain the summation of the nodes thus far in the array.
162 * Example:
163 * textNodes = [[This is ], [a], [n example]]
164 * nodeSizes = [[8], [9], [18]]
165 * This is used to determine the offset when highlighting
166 * @param String textSearch
167 * Concatination of textNodes's text content
168 * Example:
169 * textNodes = [[This is ], [a], [n example]]
170 * nodeSizes = "This is an example"
171 * This is used when executing the regular expression
172 * @param String searchPhrase
173 * word or words to search for
174 * @returns boolean
175 * Returns true when atleast one instance of search phrase is found, otherwise false
177 highlightMatches(textNodes, nodeSizes, textSearch, searchPhrase) {
178 if (!searchPhrase) {
179 return false;
182 let indices = [];
183 let i = -1;
184 while ((i = textSearch.indexOf(searchPhrase, i + 1)) >= 0) {
185 indices.push(i);
188 // Looping through each spot the searchPhrase is found in the concatenated string
189 for (let startValue of indices) {
190 let endValue = startValue + searchPhrase.length;
191 let startNode = null;
192 let endNode = null;
193 let nodeStartIndex = null;
195 // Determining the start and end node to highlight from
196 for (let index = 0; index < nodeSizes.length; index++) {
197 let lengthNodes = nodeSizes[index];
198 // Determining the start node
199 if (!startNode && lengthNodes >= startValue) {
200 startNode = textNodes[index];
201 nodeStartIndex = index;
202 // Calculating the offset when found query is not in the first node
203 if (index > 0) {
204 startValue -= nodeSizes[index - 1];
207 // Determining the end node
208 if (!endNode && lengthNodes >= endValue) {
209 endNode = textNodes[index];
210 // Calculating the offset when endNode is different from startNode
211 // or when endNode is not the first node
212 if (index != nodeStartIndex || index > 0) {
213 endValue -= nodeSizes[index - 1];
217 let range = document.createRange();
218 range.setStart(startNode, startValue);
219 range.setEnd(endNode, endValue);
220 this.getFindSelection(startNode.ownerGlobal).addRange(range);
222 this.searchResultsHighlighted = true;
225 return !!indices.length;
229 * Get the selection instance from given window
231 * @param Object win
232 * The window object points to frame's window
234 getFindSelection(win) {
235 // Yuck. See bug 138068.
236 let docShell = win.docShell;
238 let controller = docShell
239 .QueryInterface(Ci.nsIInterfaceRequestor)
240 .getInterface(Ci.nsISelectionDisplay)
241 .QueryInterface(Ci.nsISelectionController);
243 let selection = controller.getSelection(
244 Ci.nsISelectionController.SELECTION_FIND
246 selection.setColors("currentColor", "#ffe900", "currentColor", "#003eaa");
248 return selection;
252 * Shows or hides content according to search input
254 * @param String event
255 * to search for filted query in
257 async searchFunction(event) {
258 let query = event.target.value.trim().toLowerCase();
259 if (this.query == query) {
260 return;
263 let firstQuery = !this.query && query;
264 let endQuery = !query && this.query;
265 let subQuery = this.query && query.includes(this.query);
266 this.query = query;
268 // If there is a query, don't reshow the existing hidden subitems yet
269 // to avoid them flickering into view only to be hidden again by
270 // this next search.
271 this.removeAllSearchIndicators(window, !query.length);
273 let srHeader = document.getElementById("header-searchResults");
274 let noResultsEl = document.getElementById("no-results-message");
275 if (this.query) {
276 // If this is the first query, fix the search input in place.
277 if (firstQuery) {
278 this.fixInputPosition();
280 // Showing the Search Results Tag
281 await gotoPref("paneSearchResults");
282 srHeader.hidden = false;
284 let resultsFound = false;
286 // Building the range for highlighted areas
287 let rootPreferencesChildren = [
288 ...document.querySelectorAll(
289 "#mainPrefPane > *:not([data-hidden-from-search], script, stringbundle)"
293 if (subQuery) {
294 // Since the previous query is a subset of the current query,
295 // there is no need to check elements that is hidden already.
296 rootPreferencesChildren = rootPreferencesChildren.filter(
297 el => !el.hidden
301 // Attach the bindings for all children if they were not already visible.
302 for (let child of rootPreferencesChildren) {
303 if (child.hidden) {
304 child.classList.add("visually-hidden");
305 child.hidden = false;
309 let ts = performance.now();
310 let FRAME_THRESHOLD = 1000 / 60;
312 // Showing or Hiding specific section depending on if words in query are found
313 for (let child of rootPreferencesChildren) {
314 if (performance.now() - ts > FRAME_THRESHOLD) {
315 // Creating tooltips for all the instances found
316 for (let anchorNode of this.listSearchTooltips) {
317 this.createSearchTooltip(anchorNode, this.query);
319 ts = await new Promise(resolve =>
320 window.requestAnimationFrame(resolve)
322 if (query !== this.query) {
323 return;
327 if (
328 !child.classList.contains("header") &&
329 !child.classList.contains("subcategory") &&
330 (await this.searchWithinNode(child, this.query))
332 child.classList.remove("visually-hidden");
334 // Show the preceding search-header if one exists.
335 let groupbox =
336 child.closest("groupbox") || child.closest("[data-category]");
337 let groupHeader =
338 groupbox && groupbox.querySelector(".search-header");
339 if (groupHeader) {
340 groupHeader.hidden = false;
343 resultsFound = true;
344 } else {
345 child.classList.add("visually-hidden");
349 // Hide any subitems that don't match the search term and show
350 // only those that do.
351 if (this.subItems.size) {
352 for (let [subItem, matches] of this.subItems) {
353 subItem.classList.toggle("visually-hidden", !matches);
357 noResultsEl.hidden = !!resultsFound;
358 noResultsEl.setAttribute("query", this.query);
359 // XXX: This is potentially racy in case where Fluent retranslates the
360 // message and ereases the query within.
361 // The feature is not yet supported, but we should fix for it before
362 // we enable it. See bug 1446389 for details.
363 let msgQueryElem = document.getElementById("sorry-message-query");
364 msgQueryElem.textContent = this.query;
365 if (resultsFound) {
366 // Creating tooltips for all the instances found
367 for (let anchorNode of this.listSearchTooltips) {
368 this.createSearchTooltip(anchorNode, this.query);
371 } else {
372 if (endQuery) {
373 document
374 .querySelector(".sticky-inner-container")
375 .style.removeProperty("max-width");
377 noResultsEl.hidden = true;
378 document.getElementById("sorry-message-query").textContent = "";
379 // Going back to General when cleared
380 await gotoPref("paneGeneral");
381 srHeader.hidden = true;
383 // Hide some special second level headers in normal view
384 for (let element of document.querySelectorAll(".search-header")) {
385 element.hidden = true;
389 window.dispatchEvent(
390 new CustomEvent("PreferencesSearchCompleted", { detail: query })
395 * Finding leaf nodes and checking their content for words to search,
396 * It is a recursive function
398 * @param Node nodeObject
399 * DOM Element
400 * @param String searchPhrase
401 * @returns boolean
402 * Returns true when found in at least one childNode, false otherwise
404 async searchWithinNode(nodeObject, searchPhrase) {
405 let matchesFound = false;
406 if (
407 nodeObject.childElementCount == 0 ||
408 this.searchableNodes.has(nodeObject.localName) ||
409 (nodeObject.localName?.startsWith("moz-") &&
410 nodeObject.localName !== "moz-input-box")
412 let simpleTextNodes = this.textNodeDescendants(nodeObject);
413 if (nodeObject.shadowRoot) {
414 simpleTextNodes.push(
415 ...this.textNodeDescendants(nodeObject.shadowRoot)
418 for (let node of simpleTextNodes) {
419 let result = this.highlightMatches(
420 [node],
421 [node.length],
422 node.textContent.toLowerCase(),
423 searchPhrase
425 matchesFound = matchesFound || result;
428 // Collecting data from anonymous content / label / description
429 let nodeSizes = [];
430 let allNodeText = "";
431 let runningSize = 0;
433 let accessKeyTextNodes = [];
435 if (
436 nodeObject.localName == "label" ||
437 nodeObject.localName == "description"
439 accessKeyTextNodes.push(...simpleTextNodes);
442 for (let node of accessKeyTextNodes) {
443 runningSize += node.textContent.length;
444 allNodeText += node.textContent;
445 nodeSizes.push(runningSize);
448 // Access key are presented
449 let complexTextNodesResult = this.highlightMatches(
450 accessKeyTextNodes,
451 nodeSizes,
452 allNodeText.toLowerCase(),
453 searchPhrase
456 // Searching some elements, such as xul:button, have a 'label' attribute that contains the user-visible text.
457 let labelResult = this.queryMatchesContent(
458 nodeObject.getAttribute("label"),
459 searchPhrase
462 // Searching some elements, such as xul:label, store their user-visible text in a "value" attribute.
463 // Value will be skipped for menuitem since value in menuitem could represent index number to distinct each item.
464 let valueResult =
465 nodeObject.localName !== "menuitem" && nodeObject.localName !== "radio"
466 ? this.queryMatchesContent(
467 nodeObject.getAttribute("value"),
468 searchPhrase
470 : false;
472 // Searching some elements, such as xul:button, buttons to open subdialogs
473 // using l10n ids.
474 let keywordsResult =
475 nodeObject.hasAttribute("search-l10n-ids") &&
476 (await this.matchesSearchL10nIDs(nodeObject, searchPhrase));
478 if (!keywordsResult) {
479 // Searching some elements, such as xul:button, buttons to open subdialogs
480 // using searchkeywords attribute.
481 keywordsResult =
482 !keywordsResult &&
483 nodeObject.hasAttribute("searchkeywords") &&
484 this.queryMatchesContent(
485 nodeObject.getAttribute("searchkeywords"),
486 searchPhrase
490 // Creating tooltips for buttons
491 if (
492 keywordsResult &&
493 (nodeObject.localName === "button" ||
494 nodeObject.localName == "menulist")
496 this.listSearchTooltips.add(nodeObject);
499 if (keywordsResult && nodeObject.localName === "menuitem") {
500 nodeObject.setAttribute("indicator", "true");
501 this.listSearchMenuitemIndicators.add(nodeObject);
502 let menulist = nodeObject.closest("menulist");
504 menulist.setAttribute("indicator", "true");
505 this.listSearchMenuitemIndicators.add(menulist);
508 if (
509 (nodeObject.localName == "menulist" ||
510 nodeObject.localName == "menuitem") &&
511 (labelResult || valueResult || keywordsResult)
513 nodeObject.setAttribute("highlightable", "true");
516 matchesFound =
517 matchesFound ||
518 complexTextNodesResult ||
519 labelResult ||
520 valueResult ||
521 keywordsResult;
524 // Should not search unselected child nodes of a <xul:deck> element
525 // except the "historyPane" <xul:deck> element.
526 if (nodeObject.localName == "deck" && nodeObject.id != "historyPane") {
527 let index = nodeObject.selectedIndex;
528 if (index != -1) {
529 let result = await this.searchChildNodeIfVisible(
530 nodeObject,
531 index,
532 searchPhrase
534 matchesFound = matchesFound || result;
536 } else {
537 for (let i = 0; i < nodeObject.childNodes.length; i++) {
538 let result = await this.searchChildNodeIfVisible(
539 nodeObject,
541 searchPhrase
543 matchesFound = matchesFound || result;
546 return matchesFound;
550 * Search for a phrase within a child node if it is visible.
552 * @param Node nodeObject
553 * The parent DOM Element
554 * @param Number index
555 * The index for the childNode
556 * @param String searchPhrase
557 * @returns boolean
558 * Returns true when found the specific childNode, false otherwise
560 async searchChildNodeIfVisible(nodeObject, index, searchPhrase) {
561 let result = false;
562 let child = nodeObject.childNodes[index];
563 if (
564 !child.hidden &&
565 nodeObject.getAttribute("data-hidden-from-search") !== "true"
567 result = await this.searchWithinNode(child, searchPhrase);
568 // Creating tooltips for menulist element
569 if (result && nodeObject.localName === "menulist") {
570 this.listSearchTooltips.add(nodeObject);
573 // If this is a node for an experimental feature option or a Mozilla product item,
574 // add it to the list of subitems. The items that don't match the search term
575 // will be hidden.
576 if (
577 Element.isInstance(child) &&
578 (child.classList.contains("featureGate") ||
579 child.classList.contains("mozilla-product-item"))
581 this.subItems.set(child, result);
584 return result;
588 * Search for a phrase in l10n messages associated with the element.
590 * @param Node nodeObject
591 * The parent DOM Element
592 * @param String searchPhrase
593 * @returns boolean
594 * true when the text content contains the query string else false
596 async matchesSearchL10nIDs(nodeObject, searchPhrase) {
597 if (!this.searchKeywords.has(nodeObject)) {
598 // The `search-l10n-ids` attribute is a comma-separated list of
599 // l10n ids. It may also uses a dot notation to specify an attribute
600 // of the message to be used.
602 // Example: "containers-add-button.label, user-context-personal"
604 // The result is an array of arrays of l10n ids and optionally attribute names.
606 // Example: [["containers-add-button", "label"], ["user-context-personal"]]
607 const refs = nodeObject
608 .getAttribute("search-l10n-ids")
609 .split(",")
610 .map(s => s.trim().split("."))
611 .filter(s => !!s[0].length);
613 const messages = await document.l10n.formatMessages(
614 refs.map(ref => ({ id: ref[0] }))
617 // Map the localized messages taking value or a selected attribute and
618 // building a string of concatenated translated strings out of it.
619 let keywords = messages
620 .map((msg, i) => {
621 let [refId, refAttr] = refs[i];
622 if (!msg) {
623 console.error(`Missing search l10n id "${refId}"`);
624 return null;
626 if (refAttr) {
627 let attr =
628 msg.attributes && msg.attributes.find(a => a.name === refAttr);
629 if (!attr) {
630 console.error(`Missing search l10n id "${refId}.${refAttr}"`);
631 return null;
633 if (attr.value === "") {
634 console.error(
635 `Empty value added to search-l10n-ids "${refId}.${refAttr}"`
638 return attr.value;
640 if (msg.value === "") {
641 console.error(`Empty value added to search-l10n-ids "${refId}"`);
643 return msg.value;
645 .filter(keyword => keyword !== null)
646 .join(" ");
648 this.searchKeywords.set(nodeObject, keywords);
649 return this.queryMatchesContent(keywords, searchPhrase);
652 return this.queryMatchesContent(
653 this.searchKeywords.get(nodeObject),
654 searchPhrase
659 * Inserting a div structure infront of the DOM element matched textContent.
660 * Then calculation the offsets to position the tooltip in the correct place.
662 * @param Node anchorNode
663 * DOM Element
664 * @param String query
665 * Word or words that are being searched for
667 createSearchTooltip(anchorNode, query) {
668 if (anchorNode.tooltipNode) {
669 return;
671 let searchTooltip = anchorNode.ownerDocument.createElement("span");
672 let searchTooltipText = anchorNode.ownerDocument.createElement("span");
673 searchTooltip.className = "search-tooltip";
674 searchTooltipText.textContent = query;
675 searchTooltip.appendChild(searchTooltipText);
677 // Set tooltipNode property to track corresponded tooltip node.
678 anchorNode.tooltipNode = searchTooltip;
679 anchorNode.parentElement.classList.add("search-tooltip-parent");
680 anchorNode.parentElement.appendChild(searchTooltip);
682 this._applyTooltipPosition(
683 searchTooltip,
684 this._computeTooltipPosition(anchorNode, searchTooltip)
688 _recomputeTooltipPositions() {
689 let positions = [];
690 for (let anchorNode of this.listSearchTooltips) {
691 let searchTooltip = anchorNode.tooltipNode;
692 if (!searchTooltip) {
693 continue;
695 let position = this._computeTooltipPosition(anchorNode, searchTooltip);
696 positions.push({ searchTooltip, position });
698 for (let { searchTooltip, position } of positions) {
699 this._applyTooltipPosition(searchTooltip, position);
703 _applyTooltipPosition(searchTooltip, position) {
704 searchTooltip.style.left = position.left + "px";
705 searchTooltip.style.top = position.top + "px";
708 _computeTooltipPosition(anchorNode, searchTooltip) {
709 // In order to get the up-to-date position of each of the nodes that we're
710 // putting tooltips on, we have to flush layout intentionally. Once
711 // menulists don't use XUL layout we can remove this and use plain CSS to
712 // position them, see bug 1363730.
713 let anchorRect = anchorNode.getBoundingClientRect();
714 let containerRect = anchorNode.parentElement.getBoundingClientRect();
715 let tooltipRect = searchTooltip.getBoundingClientRect();
717 let left =
718 anchorRect.left -
719 containerRect.left +
720 anchorRect.width / 2 -
721 tooltipRect.width / 2;
722 let top = anchorRect.top - containerRect.top;
723 return { left, top };
727 * Remove all search indicators. This would be called when switching away from
728 * a search to another preference category.
730 removeAllSearchIndicators(window, showSubItems) {
731 if (this.searchResultsHighlighted) {
732 this.getFindSelection(window).removeAllRanges();
733 this.searchResultsHighlighted = false;
735 this.removeAllSearchTooltips();
736 this.removeAllSearchMenuitemIndicators();
738 // Make any previously hidden subitems visible again for the next search.
739 if (showSubItems && this.subItems.size) {
740 for (let subItem of this.subItems.keys()) {
741 subItem.classList.remove("visually-hidden");
743 this.subItems.clear();
748 * Remove all search tooltips.
750 removeAllSearchTooltips() {
751 for (let anchorNode of this.listSearchTooltips) {
752 anchorNode.parentElement.classList.remove("search-tooltip-parent");
753 if (anchorNode.tooltipNode) {
754 anchorNode.tooltipNode.remove();
756 anchorNode.tooltipNode = null;
758 this.listSearchTooltips.clear();
762 * Remove all indicators on menuitem.
764 removeAllSearchMenuitemIndicators() {
765 for (let node of this.listSearchMenuitemIndicators) {
766 node.removeAttribute("indicator");
768 this.listSearchMenuitemIndicators.clear();