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
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",
20 customElements
.define("highlightable-button", HighlightableButton
, {
24 var gSearchResultsPane
= {
25 listSearchTooltips
: new Set(),
26 listSearchMenuitemIndicators
: new Set(),
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(),
33 // A (node -> boolean) map of subitems to be made visible or hidden.
36 searchResultsHighlighted
: false,
38 searchableNodes
: new Set([
52 this.searchInput
= document
.getElementById("searchInput");
54 window
.addEventListener("resize", () => {
55 this._recomputeTooltipPositions();
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());
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
);
77 * This stops the search input from moving, when typing in it
78 * changes which items in the prefs are visible.
81 let innerContainer
= document
.querySelector(".sticky-inner-container");
83 window
.windowUtils
.getBoundsWithoutFlushing(innerContainer
).width
;
84 innerContainer
.style
.maxWidth
= width
+ "px";
88 * Check that the text content contains the query string.
90 * @param String content
91 * the text content to be searched
95 * true when the text content contains the query string else false
97 queryMatchesContent(content
, query
) {
98 if (!content
|| !query
) {
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()) {
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
133 * @returns array of text nodes
135 textNodeDescendants(node
) {
140 for (node
= node
.firstChild
; node
; node
= node
.nextSibling
) {
141 if (node
.nodeType
=== node
.TEXT_NODE
) {
144 all
= all
.concat(this.textNodeDescendants(node
));
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.
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
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
175 * Returns true when atleast one instance of search phrase is found, otherwise false
177 highlightMatches(textNodes
, nodeSizes
, textSearch
, searchPhrase
) {
184 while ((i
= textSearch
.indexOf(searchPhrase
, i
+ 1)) >= 0) {
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;
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
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
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");
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
) {
263 let firstQuery
= !this.query
&& query
;
264 let endQuery
= !query
&& this.query
;
265 let subQuery
= this.query
&& query
.includes(this.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
271 this.removeAllSearchIndicators(window
, !query
.length
);
273 let srHeader
= document
.getElementById("header-searchResults");
274 let noResultsEl
= document
.getElementById("no-results-message");
276 // If this is the first query, fix the search input in place.
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)"
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(
301 // Attach the bindings for all children if they were not already visible.
302 for (let child
of rootPreferencesChildren
) {
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
) {
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.
336 child
.closest("groupbox") || child
.closest("[data-category]");
338 groupbox
&& groupbox
.querySelector(".search-header");
340 groupHeader
.hidden
= false;
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
;
366 // Creating tooltips for all the instances found
367 for (let anchorNode
of this.listSearchTooltips
) {
368 this.createSearchTooltip(anchorNode
, this.query
);
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
400 * @param String searchPhrase
402 * Returns true when found in at least one childNode, false otherwise
404 async
searchWithinNode(nodeObject
, searchPhrase
) {
405 let matchesFound
= false;
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(
422 node
.textContent
.toLowerCase(),
425 matchesFound
= matchesFound
|| result
;
428 // Collecting data from anonymous content / label / description
430 let allNodeText
= "";
433 let accessKeyTextNodes
= [];
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(
452 allNodeText
.toLowerCase(),
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"),
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.
465 nodeObject
.localName
!== "menuitem" && nodeObject
.localName
!== "radio"
466 ? this.queryMatchesContent(
467 nodeObject
.getAttribute("value"),
472 // Searching some elements, such as xul:button, buttons to open subdialogs
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.
483 nodeObject
.hasAttribute("searchkeywords") &&
484 this.queryMatchesContent(
485 nodeObject
.getAttribute("searchkeywords"),
490 // Creating tooltips for buttons
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
);
509 (nodeObject
.localName
== "menulist" ||
510 nodeObject
.localName
== "menuitem") &&
511 (labelResult
|| valueResult
|| keywordsResult
)
513 nodeObject
.setAttribute("highlightable", "true");
518 complexTextNodesResult
||
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
;
529 let result
= await
this.searchChildNodeIfVisible(
534 matchesFound
= matchesFound
|| result
;
537 for (let i
= 0; i
< nodeObject
.childNodes
.length
; i
++) {
538 let result
= await
this.searchChildNodeIfVisible(
543 matchesFound
= matchesFound
|| result
;
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
558 * Returns true when found the specific childNode, false otherwise
560 async
searchChildNodeIfVisible(nodeObject
, index
, searchPhrase
) {
562 let child
= nodeObject
.childNodes
[index
];
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
577 Element
.isInstance(child
) &&
578 (child
.classList
.contains("featureGate") ||
579 child
.classList
.contains("mozilla-product-item"))
581 this.subItems
.set(child
, 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
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")
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
621 let [refId
, refAttr
] = refs
[i
];
623 console
.error(`Missing search l10n id "${refId}"`);
628 msg
.attributes
&& msg
.attributes
.find(a
=> a
.name
=== refAttr
);
630 console
.error(`Missing search l10n id "${refId}.${refAttr}"`);
633 if (attr
.value
=== "") {
635 `Empty value added to search-l10n-ids "${refId}.${refAttr}"`
640 if (msg
.value
=== "") {
641 console
.error(`Empty value added to search-l10n-ids "${refId}"`);
645 .filter(keyword
=> keyword
!== null)
648 this.searchKeywords
.set(nodeObject
, keywords
);
649 return this.queryMatchesContent(keywords
, searchPhrase
);
652 return this.queryMatchesContent(
653 this.searchKeywords
.get(nodeObject
),
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
664 * @param String query
665 * Word or words that are being searched for
667 createSearchTooltip(anchorNode
, query
) {
668 if (anchorNode
.tooltipNode
) {
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(
684 this._computeTooltipPosition(anchorNode
, searchTooltip
)
688 _recomputeTooltipPositions() {
690 for (let anchorNode
of this.listSearchTooltips
) {
691 let searchTooltip
= anchorNode
.tooltipNode
;
692 if (!searchTooltip
) {
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();
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();