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