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/.
5
6this.EXPORTED_SYMBOLS = ["Finder", "GetClipboardSearchString"];
7
8const { interfaces: Ci, classes: Cc, utils: Cu } = Components;
9
10Cu.import("resource://gre/modules/XPCOMUtils.jsm");
11Cu.import("resource://gre/modules/Geometry.jsm");
12Cu.import("resource://gre/modules/Services.jsm");
13Cu.import("resource://gre/modules/Task.jsm");
14
15XPCOMUtils.defineLazyModuleGetter(this, "BrowserUtils",
16  "resource://gre/modules/BrowserUtils.jsm");
17
18XPCOMUtils.defineLazyServiceGetter(this, "TextToSubURIService",
19                                         "@mozilla.org/intl/texttosuburi;1",
20                                         "nsITextToSubURI");
21XPCOMUtils.defineLazyServiceGetter(this, "Clipboard",
22                                         "@mozilla.org/widget/clipboard;1",
23                                         "nsIClipboard");
24XPCOMUtils.defineLazyServiceGetter(this, "ClipboardHelper",
25                                         "@mozilla.org/widget/clipboardhelper;1",
26                                         "nsIClipboardHelper");
27
28const kSelectionMaxLen = 150;
29const kMatchesCountLimitPref = "accessibility.typeaheadfind.matchesCountLimit";
30
31function Finder(docShell) {
32  this._fastFind = Cc["@mozilla.org/typeaheadfind;1"].createInstance(Ci.nsITypeAheadFind);
33  this._fastFind.init(docShell);
34
35  this._currentFoundRange = null;
36  this._docShell = docShell;
37  this._listeners = [];
38  this._previousLink = null;
39  this._searchString = null;
40  this._highlighter = null;
41
42  docShell.QueryInterface(Ci.nsIInterfaceRequestor)
43          .getInterface(Ci.nsIWebProgress)
44          .addProgressListener(this, Ci.nsIWebProgress.NOTIFY_LOCATION);
45  BrowserUtils.getRootWindow(this._docShell).addEventListener("unload",
46    this.onLocationChange.bind(this, { isTopLevel: true }));
47}
48
49Finder.prototype = {
50  get iterator() {
51    if (this._iterator)
52      return this._iterator;
53    this._iterator = Cu.import("resource://gre/modules/FinderIterator.jsm", null).FinderIterator;
54    return this._iterator;
55  },
56
57  destroy: function() {
58    if (this._iterator)
59      this._iterator.reset();
60    let window = this._getWindow();
61    if (this._highlighter && window) {
62      // if we clear all the references before we hide the highlights (in both
63      // highlighting modes), we simply can't use them to find the ranges we
64      // need to clear from the selection.
65      this._highlighter.hide(window);
66      this._highlighter.clear(window);
67    }
68    this.listeners = [];
69    this._docShell.QueryInterface(Ci.nsIInterfaceRequestor)
70      .getInterface(Ci.nsIWebProgress)
71      .removeProgressListener(this, Ci.nsIWebProgress.NOTIFY_LOCATION);
72    this._listeners = [];
73    this._currentFoundRange = this._fastFind = this._docShell = this._previousLink =
74      this._highlighter = null;
75  },
76
77  addResultListener: function (aListener) {
78    if (this._listeners.indexOf(aListener) === -1)
79      this._listeners.push(aListener);
80  },
81
82  removeResultListener: function (aListener) {
83    this._listeners = this._listeners.filter(l => l != aListener);
84  },
85
86  _notify: function (options) {
87    if (typeof options.storeResult != "boolean")
88      options.storeResult = true;
89
90    if (options.storeResult) {
91      this._searchString = options.searchString;
92      this.clipboardSearchString = options.searchString
93    }
94
95    let foundLink = this._fastFind.foundLink;
96    let linkURL = null;
97    if (foundLink) {
98      let docCharset = null;
99      let ownerDoc = foundLink.ownerDocument;
100      if (ownerDoc)
101        docCharset = ownerDoc.characterSet;
102
103      linkURL = TextToSubURIService.unEscapeURIForUI(docCharset, foundLink.href);
104    }
105
106    options.linkURL = linkURL;
107    options.rect = this._getResultRect();
108    options.searchString = this._searchString;
109
110    if (!this.iterator.continueRunning({
111      caseSensitive: this._fastFind.caseSensitive,
112      entireWord: this._fastFind.entireWord,
113      linksOnly: options.linksOnly,
114      word: options.searchString
115    })) {
116      this.iterator.stop();
117    }
118
119    this.highlighter.update(options);
120    this.requestMatchesCount(options.searchString, options.linksOnly);
121
122    this._outlineLink(options.drawOutline);
123
124    for (let l of this._listeners) {
125      try {
126        l.onFindResult(options);
127      } catch (ex) {}
128    }
129  },
130
131  get searchString() {
132    if (!this._searchString && this._fastFind.searchString)
133      this._searchString = this._fastFind.searchString;
134    return this._searchString;
135  },
136
137  get clipboardSearchString() {
138    return GetClipboardSearchString(this._getWindow()
139                                        .QueryInterface(Ci.nsIInterfaceRequestor)
140                                        .getInterface(Ci.nsIWebNavigation)
141                                        .QueryInterface(Ci.nsILoadContext));
142  },
143
144  set clipboardSearchString(aSearchString) {
145    if (!aSearchString || !Clipboard.supportsFindClipboard())
146      return;
147
148    ClipboardHelper.copyStringToClipboard(aSearchString,
149                                          Ci.nsIClipboard.kFindClipboard);
150  },
151
152  set caseSensitive(aSensitive) {
153    if (this._fastFind.caseSensitive === aSensitive)
154      return;
155    this._fastFind.caseSensitive = aSensitive;
156    this.iterator.reset();
157  },
158
159  set entireWord(aEntireWord) {
160    if (this._fastFind.entireWord === aEntireWord)
161      return;
162    this._fastFind.entireWord = aEntireWord;
163    this.iterator.reset();
164  },
165
166  get highlighter() {
167    if (this._highlighter)
168      return this._highlighter;
169
170    const {FinderHighlighter} = Cu.import("resource://gre/modules/FinderHighlighter.jsm", {});
171    return this._highlighter = new FinderHighlighter(this);
172  },
173
174  get matchesCountLimit() {
175    if (typeof this._matchesCountLimit == "number")
176      return this._matchesCountLimit;
177
178    this._matchesCountLimit = Services.prefs.getIntPref(kMatchesCountLimitPref) || 0;
179    return this._matchesCountLimit;
180  },
181
182  _lastFindResult: null,
183
184  /**
185   * Used for normal search operations, highlights the first match.
186   *
187   * @param aSearchString String to search for.
188   * @param aLinksOnly Only consider nodes that are links for the search.
189   * @param aDrawOutline Puts an outline around matched links.
190   */
191  fastFind: function (aSearchString, aLinksOnly, aDrawOutline) {
192    this._lastFindResult = this._fastFind.find(aSearchString, aLinksOnly);
193    let searchString = this._fastFind.searchString;
194    this._notify({
195      searchString,
196      result: this._lastFindResult,
197      findBackwards: false,
198      findAgain: false,
199      drawOutline: aDrawOutline,
200      linksOnly: aLinksOnly
201    });
202  },
203
204  /**
205   * Repeat the previous search. Should only be called after a previous
206   * call to Finder.fastFind.
207   *
208   * @param aFindBackwards Controls the search direction:
209   *    true: before current match, false: after current match.
210   * @param aLinksOnly Only consider nodes that are links for the search.
211   * @param aDrawOutline Puts an outline around matched links.
212   */
213  findAgain: function (aFindBackwards, aLinksOnly, aDrawOutline) {
214    this._lastFindResult = this._fastFind.findAgain(aFindBackwards, aLinksOnly);
215    let searchString = this._fastFind.searchString;
216    this._notify({
217      searchString,
218      result: this._lastFindResult,
219      findBackwards: aFindBackwards,
220      findAgain: true,
221      drawOutline: aDrawOutline,
222      linksOnly: aLinksOnly
223    });
224  },
225
226  /**
227   * Forcibly set the search string of the find clipboard to the currently
228   * selected text in the window, on supported platforms (i.e. OSX).
229   */
230  setSearchStringToSelection: function() {
231    let searchString = this.getActiveSelectionText();
232
233    // Empty strings are rather useless to search for.
234    if (!searchString.length)
235      return null;
236
237    this.clipboardSearchString = searchString;
238    return searchString;
239  },
240
241  highlight: Task.async(function* (aHighlight, aWord, aLinksOnly) {
242    yield this.highlighter.highlight(aHighlight, aWord, aLinksOnly);
243  }),
244
245  getInitialSelection: function() {
246    this._getWindow().setTimeout(() => {
247      let initialSelection = this.getActiveSelectionText();
248      for (let l of this._listeners) {
249        try {
250          l.onCurrentSelection(initialSelection, true);
251        } catch (ex) {}
252      }
253    }, 0);
254  },
255
256  getActiveSelectionText: function() {
257    let focusedWindow = {};
258    let focusedElement =
259      Services.focus.getFocusedElementForWindow(this._getWindow(), true,
260                                                focusedWindow);
261    focusedWindow = focusedWindow.value;
262
263    let selText;
264
265    if (focusedElement instanceof Ci.nsIDOMNSEditableElement &&
266        focusedElement.editor) {
267      // The user may have a selection in an input or textarea.
268      selText = focusedElement.editor.selectionController
269        .getSelection(Ci.nsISelectionController.SELECTION_NORMAL)
270        .toString();
271    } else {
272      // Look for any selected text on the actual page.
273      selText = focusedWindow.getSelection().toString();
274    }
275
276    if (!selText)
277      return "";
278
279    // Process our text to get rid of unwanted characters.
280    selText = selText.trim().replace(/\s+/g, " ");
281    let truncLength = kSelectionMaxLen;
282    if (selText.length > truncLength) {
283      let truncChar = selText.charAt(truncLength).charCodeAt(0);
284      if (truncChar >= 0xDC00 && truncChar <= 0xDFFF)
285        truncLength++;
286      selText = selText.substr(0, truncLength);
287    }
288
289    return selText;
290  },
291
292  enableSelection: function() {
293    this._fastFind.setSelectionModeAndRepaint(Ci.nsISelectionController.SELECTION_ON);
294    this._restoreOriginalOutline();
295  },
296
297  removeSelection: function() {
298    this._fastFind.collapseSelection();
299    this.enableSelection();
300    this.highlighter.clear(this._getWindow());
301  },
302
303  focusContent: function() {
304    // Allow Finder listeners to cancel focusing the content.
305    for (let l of this._listeners) {
306      try {
307        if ("shouldFocusContent" in l &&
308            !l.shouldFocusContent())
309          return;
310      } catch (ex) {
311        Cu.reportError(ex);
312      }
313    }
314
315    let fastFind = this._fastFind;
316    const fm = Cc["@mozilla.org/focus-manager;1"].getService(Ci.nsIFocusManager);
317    try {
318      // Try to find the best possible match that should receive focus and
319      // block scrolling on focus since find already scrolls. Further
320      // scrolling is due to user action, so don't override this.
321      if (fastFind.foundLink) {
322        fm.setFocus(fastFind.foundLink, fm.FLAG_NOSCROLL);
323      } else if (fastFind.foundEditable) {
324        fm.setFocus(fastFind.foundEditable, fm.FLAG_NOSCROLL);
325        fastFind.collapseSelection();
326      } else {
327        this._getWindow().focus()
328      }
329    } catch (e) {}
330  },
331
332  onFindbarClose: function() {
333    this.enableSelection();
334    this.highlighter.highlight(false);
335    this.iterator.reset();
336    BrowserUtils.trackToolbarVisibility(this._docShell, "findbar", false);
337  },
338
339  onFindbarOpen: function() {
340    BrowserUtils.trackToolbarVisibility(this._docShell, "findbar", true);
341  },
342
343  onModalHighlightChange(useModalHighlight) {
344    if (this._highlighter)
345      this._highlighter.onModalHighlightChange(useModalHighlight);
346  },
347
348  onHighlightAllChange(highlightAll) {
349    if (this._highlighter)
350      this._highlighter.onHighlightAllChange(highlightAll);
351    if (this._iterator)
352      this._iterator.reset();
353  },
354
355  keyPress: function (aEvent) {
356    let controller = this._getSelectionController(this._getWindow());
357
358    switch (aEvent.keyCode) {
359      case Ci.nsIDOMKeyEvent.DOM_VK_RETURN:
360        if (this._fastFind.foundLink) {
361          let view = this._fastFind.foundLink.ownerDocument.defaultView;
362          this._fastFind.foundLink.dispatchEvent(new view.MouseEvent("click", {
363            view: view,
364            cancelable: true,
365            bubbles: true,
366            ctrlKey: aEvent.ctrlKey,
367            altKey: aEvent.altKey,
368            shiftKey: aEvent.shiftKey,
369            metaKey: aEvent.metaKey
370          }));
371        }
372        break;
373      case Ci.nsIDOMKeyEvent.DOM_VK_TAB:
374        let direction = Services.focus.MOVEFOCUS_FORWARD;
375        if (aEvent.shiftKey) {
376          direction = Services.focus.MOVEFOCUS_BACKWARD;
377        }
378        Services.focus.moveFocus(this._getWindow(), null, direction, 0);
379        break;
380      case Ci.nsIDOMKeyEvent.DOM_VK_PAGE_UP:
381        controller.scrollPage(false);
382        break;
383      case Ci.nsIDOMKeyEvent.DOM_VK_PAGE_DOWN:
384        controller.scrollPage(true);
385        break;
386      case Ci.nsIDOMKeyEvent.DOM_VK_UP:
387        controller.scrollLine(false);
388        break;
389      case Ci.nsIDOMKeyEvent.DOM_VK_DOWN:
390        controller.scrollLine(true);
391        break;
392    }
393  },
394
395  _notifyMatchesCount: function(result = this._currentMatchesCountResult) {
396    // The `_currentFound` property is only used for internal bookkeeping.
397    delete result._currentFound;
398    result.limit = this.matchesCountLimit;
399    if (result.total == result.limit)
400      result.total = -1;
401
402    for (let l of this._listeners) {
403      try {
404        l.onMatchesCountResult(result);
405      } catch (ex) {}
406    }
407
408    this._currentMatchesCountResult = null;
409  },
410
411  requestMatchesCount: function(aWord, aLinksOnly) {
412    if (this._lastFindResult == Ci.nsITypeAheadFind.FIND_NOTFOUND ||
413        this.searchString == "" || !aWord || !this.matchesCountLimit) {
414      this._notifyMatchesCount({
415        total: 0,
416        current: 0
417      });
418      return;
419    }
420
421    let window = this._getWindow();
422    this._currentFoundRange = this._fastFind.getFoundRange();
423
424    let params = {
425      caseSensitive: this._fastFind.caseSensitive,
426      entireWord: this._fastFind.entireWord,
427      linksOnly: aLinksOnly,
428      word: aWord
429    };
430    if (!this.iterator.continueRunning(params))
431      this.iterator.stop();
432
433    this.iterator.start(Object.assign(params, {
434      finder: this,
435      limit: this.matchesCountLimit,
436      listener: this,
437      useCache: true,
438    })).then(() => {
439      // Without a valid result, there's nothing to notify about. This happens
440      // when the iterator was started before and won the race.
441      if (!this._currentMatchesCountResult || !this._currentMatchesCountResult.total)
442        return;
443      this._notifyMatchesCount();
444    });
445  },
446
447  // FinderIterator listener implementation
448
449  onIteratorRangeFound(range) {
450    let result = this._currentMatchesCountResult;
451    if (!result)
452      return;
453
454    ++result.total;
455    if (!result._currentFound) {
456      ++result.current;
457      result._currentFound = (this._currentFoundRange &&
458        range.startContainer == this._currentFoundRange.startContainer &&
459        range.startOffset == this._currentFoundRange.startOffset &&
460        range.endContainer == this._currentFoundRange.endContainer &&
461        range.endOffset == this._currentFoundRange.endOffset);
462    }
463  },
464
465  onIteratorReset() {},
466
467  onIteratorRestart({ word, linksOnly }) {
468    this.requestMatchesCount(word, linksOnly);
469  },
470
471  onIteratorStart() {
472    this._currentMatchesCountResult = {
473      total: 0,
474      current: 0,
475      _currentFound: false
476    };
477  },
478
479  _getWindow: function () {
480    if (!this._docShell)
481      return null;
482    return this._docShell.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindow);
483  },
484
485  /**
486   * Get the bounding selection rect in CSS px relative to the origin of the
487   * top-level content document.
488   */
489  _getResultRect: function () {
490    let topWin = this._getWindow();
491    let win = this._fastFind.currentWindow;
492    if (!win)
493      return null;
494
495    let selection = win.getSelection();
496    if (!selection.rangeCount || selection.isCollapsed) {
497      // The selection can be into an input or a textarea element.
498      let nodes = win.document.querySelectorAll("input, textarea");
499      for (let node of nodes) {
500        if (node instanceof Ci.nsIDOMNSEditableElement && node.editor) {
501          try {
502            let sc = node.editor.selectionController;
503            selection = sc.getSelection(Ci.nsISelectionController.SELECTION_NORMAL);
504            if (selection.rangeCount && !selection.isCollapsed) {
505              break;
506            }
507          } catch (e) {
508            // If this textarea is hidden, then its selection controller might
509            // not be intialized. Ignore the failure.
510          }
511        }
512      }
513    }
514
515    if (!selection.rangeCount || selection.isCollapsed) {
516      return null;
517    }
518
519    let utils = topWin.QueryInterface(Ci.nsIInterfaceRequestor)
520                      .getInterface(Ci.nsIDOMWindowUtils);
521
522    let scrollX = {}, scrollY = {};
523    utils.getScrollXY(false, scrollX, scrollY);
524
525    for (let frame = win; frame != topWin; frame = frame.parent) {
526      let rect = frame.frameElement.getBoundingClientRect();
527      let left = frame.getComputedStyle(frame.frameElement, "").borderLeftWidth;
528      let top = frame.getComputedStyle(frame.frameElement, "").borderTopWidth;
529      scrollX.value += rect.left + parseInt(left, 10);
530      scrollY.value += rect.top + parseInt(top, 10);
531    }
532    let rect = Rect.fromRect(selection.getRangeAt(0).getBoundingClientRect());
533    return rect.translate(scrollX.value, scrollY.value);
534  },
535
536  _outlineLink: function (aDrawOutline) {
537    let foundLink = this._fastFind.foundLink;
538
539    // Optimization: We are drawing outlines and we matched
540    // the same link before, so don't duplicate work.
541    if (foundLink == this._previousLink && aDrawOutline)
542      return;
543
544    this._restoreOriginalOutline();
545
546    if (foundLink && aDrawOutline) {
547      // Backup original outline
548      this._tmpOutline = foundLink.style.outline;
549      this._tmpOutlineOffset = foundLink.style.outlineOffset;
550
551      // Draw pseudo focus rect
552      // XXX Should we change the following style for FAYT pseudo focus?
553      // XXX Shouldn't we change default design if outline is visible
554      //     already?
555      // Don't set the outline-color, we should always use initial value.
556      foundLink.style.outline = "1px dotted";
557      foundLink.style.outlineOffset = "0";
558
559      this._previousLink = foundLink;
560    }
561  },
562
563  _restoreOriginalOutline: function () {
564    // Removes the outline around the last found link.
565    if (this._previousLink) {
566      this._previousLink.style.outline = this._tmpOutline;
567      this._previousLink.style.outlineOffset = this._tmpOutlineOffset;
568      this._previousLink = null;
569    }
570  },
571
572  _getSelectionController: function(aWindow) {
573    // display: none iframes don't have a selection controller, see bug 493658
574    try {
575      if (!aWindow.innerWidth || !aWindow.innerHeight)
576        return null;
577    } catch (e) {
578      // If getting innerWidth or innerHeight throws, we can't get a selection
579      // controller.
580      return null;
581    }
582
583    // Yuck. See bug 138068.
584    let docShell = aWindow.QueryInterface(Ci.nsIInterfaceRequestor)
585                          .getInterface(Ci.nsIWebNavigation)
586                          .QueryInterface(Ci.nsIDocShell);
587
588    let controller = docShell.QueryInterface(Ci.nsIInterfaceRequestor)
589                             .getInterface(Ci.nsISelectionDisplay)
590                             .QueryInterface(Ci.nsISelectionController);
591    return controller;
592  },
593
594  // Start of nsIWebProgressListener implementation.
595
596  onLocationChange: function(aWebProgress, aRequest, aLocation, aFlags) {
597    if (!aWebProgress.isTopLevel)
598      return;
599    // Ignore events that don't change the document.
600    if (aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT)
601      return;
602
603    // Avoid leaking if we change the page.
604    this._lastFindResult = this._previousLink = this._currentFoundRange = null;
605    this.highlighter.onLocationChange();
606    this.iterator.reset();
607  },
608
609  QueryInterface: XPCOMUtils.generateQI([Ci.nsIWebProgressListener,
610                                         Ci.nsISupportsWeakReference])
611};
612
613function GetClipboardSearchString(aLoadContext) {
614  let searchString = "";
615  if (!Clipboard.supportsFindClipboard())
616    return searchString;
617
618  try {
619    let trans = Cc["@mozilla.org/widget/transferable;1"]
620                  .createInstance(Ci.nsITransferable);
621    trans.init(aLoadContext);
622    trans.addDataFlavor("text/unicode");
623
624    Clipboard.getData(trans, Ci.nsIClipboard.kFindClipboard);
625
626    let data = {};
627    let dataLen = {};
628    trans.getTransferData("text/unicode", data, dataLen);
629    if (data.value) {
630      data = data.value.QueryInterface(Ci.nsISupportsString);
631      searchString = data.toString();
632    }
633  } catch (ex) {}
634
635  return searchString;
636}
637
638this.Finder = Finder;
639this.GetClipboardSearchString = GetClipboardSearchString;
640