1 // vim: set ts=2 sw=2 sts=2 tw=80:
2 // This Source Code Form is subject to the terms of the Mozilla Public
3 // License, v. 2.0. If a copy of the MPL was not distributed with this
4 // file, You can obtain one at http://mozilla.org/MPL/2.0/.
6 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
8 import { Rect } from "resource://gre/modules/Geometry.sys.mjs";
10 import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
14 ChromeUtils.defineESModuleGetters(lazy, {
15 FinderIterator: "resource://gre/modules/FinderIterator.sys.mjs",
16 PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
19 XPCOMUtils.defineLazyServiceGetter(
22 "@mozilla.org/widget/clipboardhelper;1",
26 const kSelectionMaxLen = 150;
27 const kMatchesCountLimitPref = "accessibility.typeaheadfind.matchesCountLimit";
29 const activeFinderRoots = new WeakSet();
31 export function Finder(docShell) {
32 this._fastFind = Cc["@mozilla.org/typeaheadfind;1"].createInstance(
35 this._fastFind.init(docShell);
37 this._currentFoundRange = null;
38 this._docShell = docShell;
40 this._previousLink = null;
41 this._searchString = null;
42 this._highlighter = null;
45 .QueryInterface(Ci.nsIInterfaceRequestor)
46 .getInterface(Ci.nsIWebProgress)
47 .addProgressListener(this, Ci.nsIWebProgress.NOTIFY_LOCATION);
48 docShell.domWindow.addEventListener(
50 this.onLocationChange.bind(this, { isTopLevel: true })
54 Finder.isFindbarVisible = function (docShell) {
55 return activeFinderRoots.has(docShell.browsingContext.top);
60 if (!this._iterator) {
61 this._iterator = new lazy.FinderIterator();
63 return this._iterator;
68 this._iterator.reset();
70 let window = this._getWindow();
71 if (this._highlighter && window) {
72 // if we clear all the references before we hide the highlights (in both
73 // highlighting modes), we simply can't use them to find the ranges we
74 // need to clear from the selection.
75 this._highlighter.hide(window);
76 this._highlighter.clear(window);
77 this.highlighter.removeScrollMarks();
81 .QueryInterface(Ci.nsIInterfaceRequestor)
82 .getInterface(Ci.nsIWebProgress)
83 .removeProgressListener(this, Ci.nsIWebProgress.NOTIFY_LOCATION);
85 this._currentFoundRange =
93 addResultListener(aListener) {
94 if (!this._listeners.includes(aListener)) {
95 this._listeners.push(aListener);
99 removeResultListener(aListener) {
100 this._listeners = this._listeners.filter(l => l != aListener);
103 _setResults(options) {
104 if (typeof options.storeResult != "boolean") {
105 options.storeResult = true;
108 if (options.storeResult) {
109 this._searchString = options.searchString;
110 this.clipboardSearchString = options.searchString;
113 let foundLink = this._fastFind.foundLink;
116 linkURL = Services.textToSubURI.unEscapeURIForUI(foundLink.href);
119 options.linkURL = linkURL;
120 options.rect = this._getResultRect();
121 options.searchString = this._searchString;
123 this._outlineLink(options.drawOutline);
125 for (let l of this._listeners) {
127 l.onFindResult(options);
133 if (!this._searchString && this._fastFind.searchString) {
134 this._searchString = this._fastFind.searchString;
136 return this._searchString;
139 get clipboardSearchString() {
140 return GetClipboardSearchString(
141 this._getWindow().docShell.QueryInterface(Ci.nsILoadContext)
145 set clipboardSearchString(aSearchString) {
146 if (!lazy.PrivateBrowsingUtils.isContentWindowPrivate(this._getWindow())) {
147 SetClipboardSearchString(aSearchString);
151 set caseSensitive(aSensitive) {
152 if (this._fastFind.caseSensitive === aSensitive) {
155 this._fastFind.caseSensitive = aSensitive;
156 this.iterator.reset();
159 set matchDiacritics(aMatchDiacritics) {
160 if (this._fastFind.matchDiacritics === aMatchDiacritics) {
163 this._fastFind.matchDiacritics = aMatchDiacritics;
164 this.iterator.reset();
167 set entireWord(aEntireWord) {
168 if (this._fastFind.entireWord === aEntireWord) {
171 this._fastFind.entireWord = aEntireWord;
172 this.iterator.reset();
176 if (this._highlighter) {
177 return this._highlighter;
180 const { FinderHighlighter } = ChromeUtils.importESModule(
181 "resource://gre/modules/FinderHighlighter.sys.mjs"
183 return (this._highlighter = new FinderHighlighter(this));
186 get matchesCountLimit() {
187 if (typeof this._matchesCountLimit == "number") {
188 return this._matchesCountLimit;
191 this._matchesCountLimit =
192 Services.prefs.getIntPref(kMatchesCountLimitPref) || 0;
193 return this._matchesCountLimit;
196 _lastFindResult: null,
199 * Used for normal search operations, highlights the first match.
200 * This method is used only for compatibility with non-remote browsers.
202 * @param aSearchString String to search for.
203 * @param aLinksOnly Only consider nodes that are links for the search.
204 * @param aDrawOutline Puts an outline around matched links.
206 fastFind(aSearchString, aLinksOnly, aDrawOutline) {
207 this._lastFindResult = this._fastFind.find(
210 Ci.nsITypeAheadFind.FIND_INITIAL,
213 let searchString = this._fastFind.searchString;
217 result: this._lastFindResult,
218 findBackwards: false,
220 drawOutline: aDrawOutline,
221 linksOnly: aLinksOnly,
225 this._setResults(results);
226 this.updateHighlightAndMatchCount(results);
228 return this._lastFindResult;
232 * Repeat the previous search. Should only be called after a previous
233 * call to Finder.fastFind.
234 * This method is used only for compatibility with non-remote browsers.
236 * @param aSearchString String to search for.
237 * @param aFindBackwards Controls the search direction:
238 * true: before current match, false: after current match.
239 * @param aLinksOnly Only consider nodes that are links for the search.
240 * @param aDrawOutline Puts an outline around matched links.
242 findAgain(aSearchString, aFindBackwards, aLinksOnly, aDrawOutline) {
243 let mode = aFindBackwards
244 ? Ci.nsITypeAheadFind.FIND_PREVIOUS
245 : Ci.nsITypeAheadFind.FIND_NEXT;
246 this._lastFindResult = this._fastFind.find(
252 let searchString = this._fastFind.searchString;
256 result: this._lastFindResult,
257 findBackwards: aFindBackwards,
259 drawOutline: aDrawOutline,
260 linksOnly: aLinksOnly,
263 this._setResults(results);
264 this.updateHighlightAndMatchCount(results);
266 return this._lastFindResult;
270 * Used for normal search operations, highlights the first or
271 * subsequent match depending on the mode.
274 * searchString String to search for.
275 * findAgain True if this a find again operation.
276 * mode Search mode from nsITypeAheadFind.
277 * linksOnly Only consider nodes that are links for the search.
278 * drawOutline Puts an outline around matched links.
279 * useSubFrames True to iterate over subframes.
280 * caseSensitive True for case sensitive searching.
281 * entireWord True to match entire words.
282 * matchDiacritics True to match diacritics.
285 this.caseSensitive = options.caseSensitive;
286 this.entireWord = options.entireWord;
287 this.matchDiacritics = options.matchDiacritics;
289 this._lastFindResult = this._fastFind.find(
290 options.searchString,
293 !options.useSubFrames
295 let searchString = this._fastFind.searchString;
298 result: this._lastFindResult,
300 options.mode == Ci.nsITypeAheadFind.FIND_PREVIOUS ||
301 options.mode == Ci.nsITypeAheadFind.FIND_LAST,
302 findAgain: options.findAgain,
303 drawOutline: options.drawOutline,
304 linksOnly: options.linksOnly,
305 entireWord: this._fastFind.entireWord,
306 useSubFrames: options.useSubFrames,
308 this._setResults(results, options.mode);
309 return new Promise(resolve => resolve(results));
313 * Forcibly set the search string of the find clipboard to the currently
314 * selected text in the window, on supported platforms (i.e. OSX).
316 setSearchStringToSelection() {
317 let searchInfo = this.getActiveSelectionText();
319 // If an empty string is returned or a subframe is focused, don't
320 // assign the search string.
321 if (searchInfo.selectedText) {
322 this.clipboardSearchString = searchInfo.selectedText;
328 async highlight(aHighlight, aWord, aLinksOnly, aUseSubFrames = true) {
329 return this.highlighter.highlight(
338 async updateHighlightAndMatchCount(aArgs) {
339 this._lastFindResult = aArgs;
342 !this.iterator.continueRunning({
343 caseSensitive: this._fastFind.caseSensitive,
344 entireWord: this._fastFind.entireWord,
345 linksOnly: aArgs.linksOnly,
346 matchDiacritics: this._fastFind.matchDiacritics,
347 word: aArgs.searchString,
348 useSubFrames: aArgs.useSubFrames,
351 this.iterator.stop();
354 let highlightPromise = this.highlighter.update(
356 aArgs.useSubFrames ? false : aArgs.foundInThisFrame
358 let matchCountPromise = this.requestMatchesCount(
364 let results = await Promise.all([highlightPromise, matchCountPromise]);
366 this.highlighter.updateScrollMarks();
369 return Object.assign(results[1], results[0]);
370 } else if (results[0]) {
377 getInitialSelection() {
378 let initialSelection = this.getActiveSelectionText().selectedText;
379 this._getWindow().setTimeout(() => {
380 for (let l of this._listeners) {
382 l.onCurrentSelection(initialSelection, true);
388 getActiveSelectionText() {
389 let focusedWindow = {};
390 let focusedElement = Services.focus.getFocusedElementForWindow(
395 focusedWindow = focusedWindow.value;
399 // If this is a remote subframe, return an empty string but
400 // indiciate which browsing context was focused.
403 "frameLoader" in focusedElement &&
404 BrowsingContext.isInstance(focusedElement.browsingContext)
407 focusedChildBrowserContextId: focusedElement.browsingContext.id,
412 if (focusedElement && focusedElement.editor) {
413 // The user may have a selection in an input or textarea.
414 selText = focusedElement.editor.selectionController
415 .getSelection(Ci.nsISelectionController.SELECTION_NORMAL)
418 // Look for any selected text on the actual page.
419 selText = focusedWindow.getSelection().toString();
423 return { selectedText: "" };
426 // Process our text to get rid of unwanted characters.
427 selText = selText.trim().replace(/\s+/g, " ");
428 let truncLength = kSelectionMaxLen;
429 if (selText.length > truncLength) {
430 let truncChar = selText.charAt(truncLength).charCodeAt(0);
431 if (truncChar >= 0xdc00 && truncChar <= 0xdfff) {
434 selText = selText.substr(0, truncLength);
437 return { selectedText: selText };
441 this._fastFind.setSelectionModeAndRepaint(
442 Ci.nsISelectionController.SELECTION_ON
444 this._restoreOriginalOutline();
447 removeSelection(keepHighlight) {
448 this._fastFind.collapseSelection();
449 this.enableSelection();
450 let window = this._getWindow();
452 this.highlighter.clearCurrentOutline(window);
454 this.highlighter.clear(window);
455 this.highlighter.removeScrollMarks();
460 // Allow Finder listeners to cancel focusing the content.
461 for (let l of this._listeners) {
463 if ("shouldFocusContent" in l && !l.shouldFocusContent()) {
471 let fastFind = this._fastFind;
473 // Try to find the best possible match that should receive focus and
474 // block scrolling on focus since find already scrolls. Further
475 // scrolling is due to user action, so don't override this.
476 if (fastFind.foundLink) {
477 Services.focus.setFocus(
479 Services.focus.FLAG_NOSCROLL
481 } else if (fastFind.foundEditable) {
482 Services.focus.setFocus(
483 fastFind.foundEditable,
484 Services.focus.FLAG_NOSCROLL
486 fastFind.collapseSelection();
488 this._getWindow().focus();
494 this.enableSelection();
495 this.highlighter.highlight(false);
496 this.highlighter.removeScrollMarks();
497 this.iterator.reset();
498 activeFinderRoots.delete(this._docShell.browsingContext.top);
502 activeFinderRoots.add(this._docShell.browsingContext.top);
505 onModalHighlightChange(useModalHighlight) {
506 if (this._highlighter) {
507 this._highlighter.onModalHighlightChange(useModalHighlight);
511 onHighlightAllChange(highlightAll) {
512 if (this._highlighter) {
513 this._highlighter.onHighlightAllChange(highlightAll);
515 if (this._iterator) {
516 this._iterator.reset();
521 let controller = this._getSelectionController(this._getWindow());
522 let accelKeyPressed =
523 AppConstants.platform == "macosx" ? aEvent.metaKey : aEvent.ctrlKey;
525 switch (aEvent.keyCode) {
526 case aEvent.DOM_VK_RETURN:
527 if (this._fastFind.foundLink) {
528 let view = this._fastFind.foundLink.ownerGlobal;
529 const ClickEventConstructor = Services.prefs.getBoolPref(
530 "dom.w3c_pointer_events.dispatch_click_as_pointer_event"
534 this._fastFind.foundLink.dispatchEvent(
535 new ClickEventConstructor("click", {
539 ctrlKey: aEvent.ctrlKey,
540 altKey: aEvent.altKey,
541 shiftKey: aEvent.shiftKey,
542 metaKey: aEvent.metaKey,
547 case aEvent.DOM_VK_TAB:
548 let direction = Services.focus.MOVEFOCUS_FORWARD;
549 if (aEvent.shiftKey) {
550 direction = Services.focus.MOVEFOCUS_BACKWARD;
552 Services.focus.moveFocus(this._getWindow(), null, direction, 0);
554 case aEvent.DOM_VK_PAGE_UP:
555 controller.scrollPage(false);
557 case aEvent.DOM_VK_PAGE_DOWN:
558 controller.scrollPage(true);
560 case aEvent.DOM_VK_UP:
561 if (accelKeyPressed) {
562 controller.completeScroll(false);
564 controller.scrollLine(false);
567 case aEvent.DOM_VK_DOWN:
568 if (accelKeyPressed) {
569 controller.completeScroll(true);
571 controller.scrollLine(true);
577 _notifyMatchesCount(aWord, result = this._currentMatchesCountResult) {
578 // The `_currentFound` property is only used for internal bookkeeping.
579 delete result._currentFound;
580 result.searchString = aWord;
581 result.limit = this.matchesCountLimit;
582 if (result.total == result.limit) {
586 for (let l of this._listeners) {
588 l.onMatchesCountResult(result);
592 this._currentMatchesCountResult = null;
596 async requestMatchesCount(aWord, aLinksOnly, aUseSubFrames = true) {
598 this._lastFindResult == Ci.nsITypeAheadFind.FIND_NOTFOUND ||
599 this.searchString == "" ||
601 !this.matchesCountLimit
603 return this._notifyMatchesCount(aWord, {
609 this._currentFoundRange = this._fastFind.getFoundRange();
612 caseSensitive: this._fastFind.caseSensitive,
613 entireWord: this._fastFind.entireWord,
614 linksOnly: aLinksOnly,
615 matchDiacritics: this._fastFind.matchDiacritics,
617 useSubFrames: aUseSubFrames,
619 if (!this.iterator.continueRunning(params)) {
620 this.iterator.stop();
623 await this.iterator.start(
624 Object.assign(params, {
626 limit: this.matchesCountLimit,
629 useSubFrames: aUseSubFrames,
633 // Without a valid result, there's nothing to notify about. This happens
634 // when the iterator was started before and won the race.
635 if (!this._currentMatchesCountResult) {
639 return this._notifyMatchesCount(aWord);
642 // FinderIterator listener implementation
644 onIteratorRangeFound(range) {
645 let result = this._currentMatchesCountResult;
651 if (!result._currentFound) {
653 result._currentFound =
654 this._currentFoundRange &&
655 range.startContainer == this._currentFoundRange.startContainer &&
656 range.startOffset == this._currentFoundRange.startOffset &&
657 range.endContainer == this._currentFoundRange.endContainer &&
658 range.endOffset == this._currentFoundRange.endOffset;
662 onIteratorReset() {},
664 onIteratorRestart({ word, linksOnly, useSubFrames }) {
665 this.requestMatchesCount(word, linksOnly, useSubFrames);
669 this._currentMatchesCountResult = {
672 _currentFound: false,
677 if (!this._docShell) {
680 return this._docShell.domWindow;
684 * Get the bounding selection rect in CSS px relative to the origin of the
685 * top-level content document.
688 let topWin = this._getWindow();
689 let win = this._fastFind.currentWindow;
694 let selection = win.getSelection();
695 if (!selection.rangeCount || selection.isCollapsed) {
696 // The selection can be into an input or a textarea element.
697 let nodes = win.document.querySelectorAll("input, textarea");
698 for (let node of nodes) {
701 let sc = node.editor.selectionController;
702 selection = sc.getSelection(
703 Ci.nsISelectionController.SELECTION_NORMAL
705 if (selection.rangeCount && !selection.isCollapsed) {
709 // If this textarea is hidden, then its selection controller might
710 // not be intialized. Ignore the failure.
716 if (!selection.rangeCount || selection.isCollapsed) {
720 let utils = topWin.windowUtils;
724 utils.getScrollXY(false, scrollX, scrollY);
726 for (let frame = win; frame != topWin; frame = frame.parent) {
727 let rect = frame.frameElement.getBoundingClientRect();
728 let left = frame.getComputedStyle(frame.frameElement).borderLeftWidth;
729 let top = frame.getComputedStyle(frame.frameElement).borderTopWidth;
730 scrollX.value += rect.left + parseInt(left, 10);
731 scrollY.value += rect.top + parseInt(top, 10);
733 let rect = Rect.fromRect(selection.getRangeAt(0).getBoundingClientRect());
734 return rect.translate(scrollX.value, scrollY.value);
737 _outlineLink(aDrawOutline) {
738 let foundLink = this._fastFind.foundLink;
740 // Optimization: We are drawing outlines and we matched
741 // the same link before, so don't duplicate work.
742 if (foundLink == this._previousLink && aDrawOutline) {
746 this._restoreOriginalOutline();
748 if (foundLink && aDrawOutline) {
749 // Backup original outline
750 this._tmpOutline = foundLink.style.outline;
751 this._tmpOutlineOffset = foundLink.style.outlineOffset;
753 // Draw pseudo focus rect
754 // XXX Should we change the following style for FAYT pseudo focus?
755 // XXX Shouldn't we change default design if outline is visible
757 // Don't set the outline-color, we should always use initial value.
758 foundLink.style.outline = "1px dotted";
759 foundLink.style.outlineOffset = "0";
761 this._previousLink = foundLink;
765 _restoreOriginalOutline() {
766 // Removes the outline around the last found link.
767 if (this._previousLink) {
768 this._previousLink.style.outline = this._tmpOutline;
769 this._previousLink.style.outlineOffset = this._tmpOutlineOffset;
770 this._previousLink = null;
774 _getSelectionController(aWindow) {
775 // display: none iframes don't have a selection controller, see bug 493658
777 if (!aWindow.innerWidth || !aWindow.innerHeight) {
781 // If getting innerWidth or innerHeight throws, we can't get a selection
786 // Yuck. See bug 138068.
787 let docShell = aWindow.docShell;
789 let controller = docShell
790 .QueryInterface(Ci.nsIInterfaceRequestor)
791 .getInterface(Ci.nsISelectionDisplay)
792 .QueryInterface(Ci.nsISelectionController);
796 // Start of nsIWebProgressListener implementation.
798 onLocationChange(aWebProgress, aRequest, aLocation, aFlags) {
799 if (!aWebProgress.isTopLevel) {
802 // Ignore events that don't change the document.
803 if (aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT) {
807 // Avoid leaking if we change the page.
808 this._lastFindResult = this._previousLink = this._currentFoundRange = null;
809 this.highlighter.onLocationChange();
810 this.iterator.reset();
813 QueryInterface: ChromeUtils.generateQI([
814 "nsIWebProgressListener",
815 "nsISupportsWeakReference",
819 export function GetClipboardSearchString(aLoadContext) {
820 let searchString = "";
822 !Services.clipboard.isClipboardTypeSupported(
823 Services.clipboard.kFindClipboard
830 let trans = Cc["@mozilla.org/widget/transferable;1"].createInstance(
833 trans.init(aLoadContext);
834 trans.addDataFlavor("text/plain");
836 Services.clipboard.getData(trans, Ci.nsIClipboard.kFindClipboard);
839 trans.getTransferData("text/plain", data);
841 data = data.value.QueryInterface(Ci.nsISupportsString);
842 searchString = data.toString();
849 export function SetClipboardSearchString(aSearchString) {
852 !Services.clipboard.isClipboardTypeSupported(
853 Services.clipboard.kFindClipboard
859 lazy.ClipboardHelper.copyStringToClipboard(
861 Ci.nsIClipboard.kFindClipboard