Bumping gaia.json for 1 gaia revision(s) a=gaia-bump
[gecko.git] / toolkit / modules / Finder.jsm
blob10b4b0f775bb6d62bcfe1660a74fac51fff129af
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
3 // file, You can obtain one at http://mozilla.org/MPL/2.0/.
5 this.EXPORTED_SYMBOLS = ["Finder"];
7 const Ci = Components.interfaces;
8 const Cc = Components.classes;
9 const Cu = Components.utils;
11 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
12 Cu.import("resource://gre/modules/Geometry.jsm");
13 Cu.import("resource://gre/modules/Services.jsm");
15 XPCOMUtils.defineLazyServiceGetter(this, "TextToSubURIService",
16                                          "@mozilla.org/intl/texttosuburi;1",
17                                          "nsITextToSubURI");
18 XPCOMUtils.defineLazyServiceGetter(this, "Clipboard",
19                                          "@mozilla.org/widget/clipboard;1",
20                                          "nsIClipboard");
21 XPCOMUtils.defineLazyServiceGetter(this, "ClipboardHelper",
22                                          "@mozilla.org/widget/clipboardhelper;1",
23                                          "nsIClipboardHelper");
25 function Finder(docShell) {
26   this._fastFind = Cc["@mozilla.org/typeaheadfind;1"].createInstance(Ci.nsITypeAheadFind);
27   this._fastFind.init(docShell);
29   this._docShell = docShell;
30   this._listeners = [];
31   this._previousLink = null;
32   this._searchString = null;
34   docShell.QueryInterface(Ci.nsIInterfaceRequestor)
35           .getInterface(Ci.nsIWebProgress)
36           .addProgressListener(this, Ci.nsIWebProgress.NOTIFY_LOCATION);
39 Finder.prototype = {
40   addResultListener: function (aListener) {
41     if (this._listeners.indexOf(aListener) === -1)
42       this._listeners.push(aListener);
43   },
45   removeResultListener: function (aListener) {
46     this._listeners = this._listeners.filter(l => l != aListener);
47   },
49   _notify: function (aSearchString, aResult, aFindBackwards, aDrawOutline, aStoreResult = true) {
50     if (aStoreResult) {
51       this._searchString = aSearchString;
52       this.clipboardSearchString = aSearchString
53     }
54     this._outlineLink(aDrawOutline);
56     let foundLink = this._fastFind.foundLink;
57     let linkURL = null;
58     if (foundLink) {
59       let docCharset = null;
60       let ownerDoc = foundLink.ownerDocument;
61       if (ownerDoc)
62         docCharset = ownerDoc.characterSet;
64       linkURL = TextToSubURIService.unEscapeURIForUI(docCharset, foundLink.href);
65     }
67     let data = {
68       result: aResult,
69       findBackwards: aFindBackwards,
70       linkURL: linkURL,
71       rect: this._getResultRect(),
72       searchString: this._searchString,
73       storeResult: aStoreResult
74     };
76     for (let l of this._listeners) {
77       try {
78         l.onFindResult(data);
79       } catch (ex) {}
80     }
81   },
83   get searchString() {
84     if (!this._searchString && this._fastFind.searchString)
85       this._searchString = this._fastFind.searchString;
86     return this._searchString;
87   },
89   get clipboardSearchString() {
90     let searchString = "";
91     if (!Clipboard.supportsFindClipboard())
92       return searchString;
94     try {
95       let trans = Cc["@mozilla.org/widget/transferable;1"]
96                     .createInstance(Ci.nsITransferable);
97       trans.init(this._getWindow()
98                      .QueryInterface(Ci.nsIInterfaceRequestor)
99                      .getInterface(Ci.nsIWebNavigation)
100                      .QueryInterface(Ci.nsILoadContext));
101       trans.addDataFlavor("text/unicode");
103       Clipboard.getData(trans, Ci.nsIClipboard.kFindClipboard);
105       let data = {};
106       let dataLen = {};
107       trans.getTransferData("text/unicode", data, dataLen);
108       if (data.value) {
109         data = data.value.QueryInterface(Ci.nsISupportsString);
110         searchString = data.toString();
111       }
112     } catch (ex) {}
114     return searchString;
115   },
117   set clipboardSearchString(aSearchString) {
118     if (!aSearchString || !Clipboard.supportsFindClipboard())
119       return;
121     ClipboardHelper.copyStringToClipboard(aSearchString,
122                                           Ci.nsIClipboard.kFindClipboard,
123                                           this._getWindow().document);
124   },
126   set caseSensitive(aSensitive) {
127     this._fastFind.caseSensitive = aSensitive;
128   },
130   /**
131    * Used for normal search operations, highlights the first match.
132    *
133    * @param aSearchString String to search for.
134    * @param aLinksOnly Only consider nodes that are links for the search.
135    * @param aDrawOutline Puts an outline around matched links.
136    */
137   fastFind: function (aSearchString, aLinksOnly, aDrawOutline) {
138     let result = this._fastFind.find(aSearchString, aLinksOnly);
139     let searchString = this._fastFind.searchString;
140     this._notify(searchString, result, false, aDrawOutline);
141   },
143   /**
144    * Repeat the previous search. Should only be called after a previous
145    * call to Finder.fastFind.
146    *
147    * @param aFindBackwards Controls the search direction:
148    *    true: before current match, false: after current match.
149    * @param aLinksOnly Only consider nodes that are links for the search.
150    * @param aDrawOutline Puts an outline around matched links.
151    */
152   findAgain: function (aFindBackwards, aLinksOnly, aDrawOutline) {
153     let result = this._fastFind.findAgain(aFindBackwards, aLinksOnly);
154     let searchString = this._fastFind.searchString;
155     this._notify(searchString, result, aFindBackwards, aDrawOutline);
156   },
158   /**
159    * Forcibly set the search string of the find clipboard to the currently
160    * selected text in the window, on supported platforms (i.e. OSX).
161    */
162   setSearchStringToSelection: function() {
163     // Find the selected text.
164     let selection = this._getWindow().getSelection();
165     // Don't go for empty selections.
166     if (!selection.rangeCount)
167       return null;
168     let searchString = (selection.toString() || "").trim();
169     // Empty strings are rather useless to search for.
170     if (!searchString.length)
171       return null;
173     this.clipboardSearchString = searchString;
174     return searchString;
175   },
177   highlight: function (aHighlight, aWord) {
178     let found = this._highlight(aHighlight, aWord, null);
179     if (aHighlight) {
180       let result = found ? Ci.nsITypeAheadFind.FIND_FOUND
181                          : Ci.nsITypeAheadFind.FIND_NOTFOUND;
182       this._notify(aWord, result, false, false, false);
183     }
184   },
186   enableSelection: function() {
187     this._fastFind.setSelectionModeAndRepaint(Ci.nsISelectionController.SELECTION_ON);
188     this._restoreOriginalOutline();
189   },
191   removeSelection: function() {
192     this._fastFind.collapseSelection();
193     this.enableSelection();
194   },
196   focusContent: function() {
197     // Allow Finder listeners to cancel focusing the content.
198     for (let l of this._listeners) {
199       try {
200         if (!l.shouldFocusContent())
201           return;
202       } catch (ex) {}
203     }
205     let fastFind = this._fastFind;
206     const fm = Cc["@mozilla.org/focus-manager;1"].getService(Ci.nsIFocusManager);
207     try {
208       // Try to find the best possible match that should receive focus and
209       // block scrolling on focus since find already scrolls. Further
210       // scrolling is due to user action, so don't override this.
211       if (fastFind.foundLink) {
212         fm.setFocus(fastFind.foundLink, fm.FLAG_NOSCROLL);
213       } else if (fastFind.foundEditable) {
214         fm.setFocus(fastFind.foundEditable, fm.FLAG_NOSCROLL);
215         fastFind.collapseSelection();
216       } else {
217         this._getWindow().focus()
218       }
219     } catch (e) {}
220   },
222   keyPress: function (aEvent) {
223     let controller = this._getSelectionController(this._getWindow());
225     switch (aEvent.keyCode) {
226       case Ci.nsIDOMKeyEvent.DOM_VK_RETURN:
227         if (this._fastFind.foundLink) {
228           let view = this._fastFind.foundLink.ownerDocument.defaultView;
229           this._fastFind.foundLink.dispatchEvent(new view.MouseEvent("click", {
230             view: view,
231             cancelable: true,
232             bubbles: true,
233             ctrlKey: aEvent.ctrlKey,
234             altKey: aEvent.altKey,
235             shiftKey: aEvent.shiftKey,
236             metaKey: aEvent.metaKey
237           }));
238         }
239         break;
240       case Ci.nsIDOMKeyEvent.DOM_VK_TAB:
241         let direction = Services.focus.MOVEFOCUS_FORWARD;
242         if (aEvent.shiftKey) {
243           direction = Services.focus.MOVEFOCUS_BACKWARD;
244         }
245         Services.focus.moveFocus(this._getWindow(), null, direction, 0);
246         break;
247       case Ci.nsIDOMKeyEvent.DOM_VK_PAGE_UP:
248         controller.scrollPage(false);
249         break;
250       case Ci.nsIDOMKeyEvent.DOM_VK_PAGE_DOWN:
251         controller.scrollPage(true);
252         break;
253       case Ci.nsIDOMKeyEvent.DOM_VK_UP:
254         controller.scrollLine(false);
255         break;
256       case Ci.nsIDOMKeyEvent.DOM_VK_DOWN:
257         controller.scrollLine(true);
258         break;
259     }
260   },
262   requestMatchesCount: function(aWord, aMatchLimit, aLinksOnly) {
263     let window = this._getWindow();
264     let result = this._countMatchesInWindow(aWord, aMatchLimit, aLinksOnly, window);
266     // Count matches in (i)frames AFTER searching through the main window.
267     for (let frame of result._framesToCount) {
268       // We've reached our limit; no need to do more work.
269       if (result.total == -1 || result.total == aMatchLimit)
270         break;
271       this._countMatchesInWindow(aWord, aMatchLimit, aLinksOnly, frame, result);
272     }
274     // The `_currentFound` and `_framesToCount` properties are only used for
275     // internal bookkeeping between recursive calls.
276     delete result._currentFound;
277     delete result._framesToCount;
279     for (let l of this._listeners) {
280       try {
281         l.onMatchesCountResult(result);
282       } catch (ex) {}
283     }
284   },
286   /**
287    * Counts the number of matches for the searched word in the passed window's
288    * content.
289    * @param aWord
290    *        the word to search for.
291    * @param aMatchLimit
292    *        the maximum number of matches shown (for speed reasons).
293    * @param aLinksOnly
294    *        whether we should only search through links.
295    * @param aWindow
296    *        the window to search in. Passing undefined will search the
297    *        current content window. Optional.
298    * @param aStats
299    *        the Object that is returned by this function. It may be passed as an
300    *        argument here in the case of a recursive call.
301    * @returns an object stating the number of matches and a vector for the current match.
302    */
303   _countMatchesInWindow: function(aWord, aMatchLimit, aLinksOnly, aWindow = null, aStats = null) {
304     aWindow = aWindow || this._getWindow();
305     aStats = aStats || {
306       total: 0,
307       current: 0,
308       _framesToCount: new Set(),
309       _currentFound: false
310     };
312     // If we already reached our max, there's no need to do more work!
313     if (aStats.total == -1 || aStats.total == aMatchLimit) {
314       aStats.total = -1;
315       return aStats;
316     }
318     this._collectFrames(aWindow, aStats);
320     let foundRange = this._fastFind.getFoundRange();
322     this._findIterator(aWord, aWindow, aRange => {
323       if (!aLinksOnly || this._rangeStartsInLink(aRange)) {
324         ++aStats.total;
325         if (!aStats._currentFound) {
326           ++aStats.current;
327           aStats._currentFound = (foundRange &&
328             aRange.startContainer == foundRange.startContainer &&
329             aRange.startOffset == foundRange.startOffset &&
330             aRange.endContainer == foundRange.endContainer &&
331             aRange.endOffset == foundRange.endOffset);
332         }
333       }
334       if (aStats.total == aMatchLimit) {
335         aStats.total = -1;
336         return false;
337       }
338     });
340     return aStats;
341   },
343   /**
344    * Basic wrapper around nsIFind that provides invoking a callback `aOnFind`
345    * each time an occurence of `aWord` string is found.
346    *
347    * @param aWord
348    *        the word to search for.
349    * @param aWindow
350    *        the window to search in.
351    * @param aOnFind
352    *        the Function to invoke when a word is found. if Boolean `false` is
353    *        returned, the find operation will be stopped and the Function will
354    *        not be invoked again.
355    */
356   _findIterator: function(aWord, aWindow, aOnFind) {
357     let doc = aWindow.document;
358     let body = (doc instanceof Ci.nsIDOMHTMLDocument && doc.body) ?
359                doc.body : doc.documentElement;
361     if (!body)
362       return;
364     let searchRange = doc.createRange();
365     searchRange.selectNodeContents(body);
367     let startPt = searchRange.cloneRange();
368     startPt.collapse(true);
370     let endPt = searchRange.cloneRange();
371     endPt.collapse(false);
373     let retRange = null;
375     let finder = Cc["@mozilla.org/embedcomp/rangefind;1"]
376                    .createInstance()
377                    .QueryInterface(Ci.nsIFind);
378     finder.caseSensitive = this._fastFind.caseSensitive;
380     while ((retRange = finder.Find(aWord, searchRange, startPt, endPt))) {
381       if (aOnFind(retRange) === false)
382         break;
383       startPt = retRange.cloneRange();
384       startPt.collapse(false);
385     }
386   },
388   /**
389    * Helper method for `_countMatchesInWindow` that recursively collects all
390    * visible (i)frames inside a window.
391    *
392    * @param aWindow
393    *        the window to extract the (i)frames from.
394    * @param aStats
395    *        Object that contains a Set called '_framesToCount'
396    */
397   _collectFrames: function(aWindow, aStats) {
398     if (!aWindow.frames || !aWindow.frames.length)
399       return;
400     // Casting `aWindow.frames` to an Iterator doesn't work, so we're stuck with
401     // a plain, old for-loop.
402     for (let i = 0, l = aWindow.frames.length; i < l; ++i) {
403       let frame = aWindow.frames[i];
404       // Don't count matches in hidden frames.
405       let frameEl = frame && frame.frameElement;
406       if (!frameEl)
407         continue;
408       // Construct a range around the frame element to check its visiblity.
409       let range = aWindow.document.createRange();
410       range.setStart(frameEl, 0);
411       range.setEnd(frameEl, 0);
412       if (!this._fastFind.isRangeVisible(range, this._getDocShell(range), true))
413         continue;
414       // All good, so add it to the set to count later.
415       if (!aStats._framesToCount.has(frame))
416         aStats._framesToCount.add(frame);
417       this._collectFrames(frame, aStats);
418     }
419   },
421   /**
422    * Helper method to extract the docShell reference from a Window or Range object.
423    *
424    * @param aWindowOrRange
425    *        Window object to query. May also be a Range, from which the owner
426    *        window will be queried.
427    * @returns nsIDocShell
428    */
429   _getDocShell: function(aWindowOrRange) {
430     let window = aWindowOrRange;
431     // Ranges may also be passed in, so fetch its window.
432     if (aWindowOrRange instanceof Ci.nsIDOMRange)
433       window = aWindowOrRange.startContainer.ownerDocument.defaultView;
434     return window.QueryInterface(Ci.nsIInterfaceRequestor)
435                  .getInterface(Ci.nsIWebNavigation)
436                  .QueryInterface(Ci.nsIDocShell);
437   },
439   _getWindow: function () {
440     return this._docShell.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindow);
441   },
443   /**
444    * Get the bounding selection rect in CSS px relative to the origin of the
445    * top-level content document.
446    */
447   _getResultRect: function () {
448     let topWin = this._getWindow();
449     let win = this._fastFind.currentWindow;
450     if (!win)
451       return null;
453     let selection = win.getSelection();
454     if (!selection.rangeCount || selection.isCollapsed) {
455       // The selection can be into an input or a textarea element.
456       let nodes = win.document.querySelectorAll("input, textarea");
457       for (let node of nodes) {
458         if (node instanceof Ci.nsIDOMNSEditableElement && node.editor) {
459           let sc = node.editor.selectionController;
460           selection = sc.getSelection(Ci.nsISelectionController.SELECTION_NORMAL);
461           if (selection.rangeCount && !selection.isCollapsed) {
462             break;
463           }
464         }
465       }
466     }
468     let utils = topWin.QueryInterface(Ci.nsIInterfaceRequestor)
469                       .getInterface(Ci.nsIDOMWindowUtils);
471     let scrollX = {}, scrollY = {};
472     utils.getScrollXY(false, scrollX, scrollY);
474     for (let frame = win; frame != topWin; frame = frame.parent) {
475       let rect = frame.frameElement.getBoundingClientRect();
476       let left = frame.getComputedStyle(frame.frameElement, "").borderLeftWidth;
477       let top = frame.getComputedStyle(frame.frameElement, "").borderTopWidth;
478       scrollX.value += rect.left + parseInt(left, 10);
479       scrollY.value += rect.top + parseInt(top, 10);
480     }
481     let rect = Rect.fromRect(selection.getRangeAt(0).getBoundingClientRect());
482     return rect.translate(scrollX.value, scrollY.value);
483   },
485   _outlineLink: function (aDrawOutline) {
486     let foundLink = this._fastFind.foundLink;
488     // Optimization: We are drawing outlines and we matched
489     // the same link before, so don't duplicate work.
490     if (foundLink == this._previousLink && aDrawOutline)
491       return;
493     this._restoreOriginalOutline();
495     if (foundLink && aDrawOutline) {
496       // Backup original outline
497       this._tmpOutline = foundLink.style.outline;
498       this._tmpOutlineOffset = foundLink.style.outlineOffset;
500       // Draw pseudo focus rect
501       // XXX Should we change the following style for FAYT pseudo focus?
502       // XXX Shouldn't we change default design if outline is visible
503       //     already?
504       // Don't set the outline-color, we should always use initial value.
505       foundLink.style.outline = "1px dotted";
506       foundLink.style.outlineOffset = "0";
508       this._previousLink = foundLink;
509     }
510   },
512   _restoreOriginalOutline: function () {
513     // Removes the outline around the last found link.
514     if (this._previousLink) {
515       this._previousLink.style.outline = this._tmpOutline;
516       this._previousLink.style.outlineOffset = this._tmpOutlineOffset;
517       this._previousLink = null;
518     }
519   },
521   _highlight: function (aHighlight, aWord, aWindow) {
522     let win = aWindow || this._getWindow();
524     let found = false;
525     for (let i = 0; win.frames && i < win.frames.length; i++) {
526       if (this._highlight(aHighlight, aWord, win.frames[i]))
527         found = true;
528     }
530     let controller = this._getSelectionController(win);
531     let doc = win.document;
532     if (!controller || !doc || !doc.documentElement) {
533       // Without the selection controller,
534       // we are unable to (un)highlight any matches
535       return found;
536     }
538     if (aHighlight) {
539       this._findIterator(aWord, win, aRange => {
540         this._highlightRange(aRange, controller);
541         found = true;
542       });
543     } else {
544       // First, attempt to remove highlighting from main document
545       let sel = controller.getSelection(Ci.nsISelectionController.SELECTION_FIND);
546       sel.removeAllRanges();
548       // Next, check our editor cache, for editors belonging to this
549       // document
550       if (this._editors) {
551         for (let x = this._editors.length - 1; x >= 0; --x) {
552           if (this._editors[x].document == doc) {
553             sel = this._editors[x].selectionController
554                                   .getSelection(Ci.nsISelectionController.SELECTION_FIND);
555             sel.removeAllRanges();
556             // We don't need to listen to this editor any more
557             this._unhookListenersAtIndex(x);
558           }
559         }
560       }
562       // Removing the highlighting always succeeds, so return true.
563       found = true;
564     }
566     return found;
567   },
569   _highlightRange: function(aRange, aController) {
570     let node = aRange.startContainer;
571     let controller = aController;
573     let editableNode = this._getEditableNode(node);
574     if (editableNode)
575       controller = editableNode.editor.selectionController;
577     let findSelection = controller.getSelection(Ci.nsISelectionController.SELECTION_FIND);
578     findSelection.addRange(aRange);
580     if (editableNode) {
581       // Highlighting added, so cache this editor, and hook up listeners
582       // to ensure we deal properly with edits within the highlighting
583       if (!this._editors) {
584         this._editors = [];
585         this._stateListeners = [];
586       }
588       let existingIndex = this._editors.indexOf(editableNode.editor);
589       if (existingIndex == -1) {
590         let x = this._editors.length;
591         this._editors[x] = editableNode.editor;
592         this._stateListeners[x] = this._createStateListener();
593         this._editors[x].addEditActionListener(this);
594         this._editors[x].addDocumentStateListener(this._stateListeners[x]);
595       }
596     }
597   },
599   _getSelectionController: function(aWindow) {
600     // display: none iframes don't have a selection controller, see bug 493658
601     if (!aWindow.innerWidth || !aWindow.innerHeight)
602       return null;
604     // Yuck. See bug 138068.
605     let docShell = aWindow.QueryInterface(Ci.nsIInterfaceRequestor)
606                           .getInterface(Ci.nsIWebNavigation)
607                           .QueryInterface(Ci.nsIDocShell);
609     let controller = docShell.QueryInterface(Ci.nsIInterfaceRequestor)
610                              .getInterface(Ci.nsISelectionDisplay)
611                              .QueryInterface(Ci.nsISelectionController);
612     return controller;
613   },
615   /*
616    * For a given node, walk up it's parent chain, to try and find an
617    * editable node.
618    *
619    * @param aNode the node we want to check
620    * @returns the first node in the parent chain that is editable,
621    *          null if there is no such node
622    */
623   _getEditableNode: function (aNode) {
624     while (aNode) {
625       if (aNode instanceof Ci.nsIDOMNSEditableElement)
626         return aNode.editor ? aNode : null;
628       aNode = aNode.parentNode;
629     }
630     return null;
631   },
633   /*
634    * Helper method to unhook listeners, remove cached editors
635    * and keep the relevant arrays in sync
636    *
637    * @param aIndex the index into the array of editors/state listeners
638    *        we wish to remove
639    */
640   _unhookListenersAtIndex: function (aIndex) {
641     this._editors[aIndex].removeEditActionListener(this);
642     this._editors[aIndex]
643         .removeDocumentStateListener(this._stateListeners[aIndex]);
644     this._editors.splice(aIndex, 1);
645     this._stateListeners.splice(aIndex, 1);
646     if (!this._editors.length) {
647       delete this._editors;
648       delete this._stateListeners;
649     }
650   },
652   /*
653    * Remove ourselves as an nsIEditActionListener and
654    * nsIDocumentStateListener from a given cached editor
655    *
656    * @param aEditor the editor we no longer wish to listen to
657    */
658   _removeEditorListeners: function (aEditor) {
659     // aEditor is an editor that we listen to, so therefore must be
660     // cached. Find the index of this editor
661     let idx = this._editors.indexOf(aEditor);
662     if (idx == -1)
663       return;
664     // Now unhook ourselves, and remove our cached copy
665     this._unhookListenersAtIndex(idx);
666   },
668   /*
669    * nsIEditActionListener logic follows
670    *
671    * We implement this interface to allow us to catch the case where
672    * the findbar found a match in a HTML <input> or <textarea>. If the
673    * user adjusts the text in some way, it will no longer match, so we
674    * want to remove the highlight, rather than have it expand/contract
675    * when letters are added or removed.
676    */
678   /*
679    * Helper method used to check whether a selection intersects with
680    * some highlighting
681    *
682    * @param aSelectionRange the range from the selection to check
683    * @param aFindRange the highlighted range to check against
684    * @returns true if they intersect, false otherwise
685    */
686   _checkOverlap: function (aSelectionRange, aFindRange) {
687     // The ranges overlap if one of the following is true:
688     // 1) At least one of the endpoints of the deleted selection
689     //    is in the find selection
690     // 2) At least one of the endpoints of the find selection
691     //    is in the deleted selection
692     if (aFindRange.isPointInRange(aSelectionRange.startContainer,
693                                   aSelectionRange.startOffset))
694       return true;
695     if (aFindRange.isPointInRange(aSelectionRange.endContainer,
696                                   aSelectionRange.endOffset))
697       return true;
698     if (aSelectionRange.isPointInRange(aFindRange.startContainer,
699                                        aFindRange.startOffset))
700       return true;
701     if (aSelectionRange.isPointInRange(aFindRange.endContainer,
702                                        aFindRange.endOffset))
703       return true;
705     return false;
706   },
708   /*
709    * Helper method to determine if an edit occurred within a highlight
710    *
711    * @param aSelection the selection we wish to check
712    * @param aNode the node we want to check is contained in aSelection
713    * @param aOffset the offset into aNode that we want to check
714    * @returns the range containing (aNode, aOffset) or null if no ranges
715    *          in the selection contain it
716    */
717   _findRange: function (aSelection, aNode, aOffset) {
718     let rangeCount = aSelection.rangeCount;
719     let rangeidx = 0;
720     let foundContainingRange = false;
721     let range = null;
723     // Check to see if this node is inside one of the selection's ranges
724     while (!foundContainingRange && rangeidx < rangeCount) {
725       range = aSelection.getRangeAt(rangeidx);
726       if (range.isPointInRange(aNode, aOffset)) {
727         foundContainingRange = true;
728         break;
729       }
730       rangeidx++;
731     }
733     if (foundContainingRange)
734       return range;
736     return null;
737   },
739   /**
740    * Determines whether a range is inside a link.
741    * @param aRange
742    *        the range to check
743    * @returns true if the range starts in a link
744    */
745   _rangeStartsInLink: function(aRange) {
746     let isInsideLink = false;
747     let node = aRange.startContainer;
749     if (node.nodeType == node.ELEMENT_NODE) {
750       if (node.hasChildNodes) {
751         let childNode = node.item(aRange.startOffset);
752         if (childNode)
753           node = childNode;
754       }
755     }
757     const XLink_NS = "http://www.w3.org/1999/xlink";
758     do {
759       if (node instanceof HTMLAnchorElement) {
760         isInsideLink = node.hasAttribute("href");
761         break;
762       } else if (typeof node.hasAttributeNS == "function" &&
763                  node.hasAttributeNS(XLink_NS, "href")) {
764         isInsideLink = (node.getAttributeNS(XLink_NS, "type") == "simple");
765         break;
766       }
768       node = node.parentNode;
769     } while (node);
771     return isInsideLink;
772   },
774   // Start of nsIWebProgressListener implementation.
776   onLocationChange: function(aWebProgress, aRequest, aLocation, aFlags) {
777     if (!aWebProgress.isTopLevel)
778       return;
780     // Avoid leaking if we change the page.
781     this._previousLink = null;
782   },
784   // Start of nsIEditActionListener implementations
786   WillDeleteText: function (aTextNode, aOffset, aLength) {
787     let editor = this._getEditableNode(aTextNode).editor;
788     let controller = editor.selectionController;
789     let fSelection = controller.getSelection(Ci.nsISelectionController.SELECTION_FIND);
790     let range = this._findRange(fSelection, aTextNode, aOffset);
792     if (range) {
793       // Don't remove the highlighting if the deleted text is at the
794       // end of the range
795       if (aTextNode != range.endContainer ||
796           aOffset != range.endOffset) {
797         // Text within the highlight is being removed - the text can
798         // no longer be a match, so remove the highlighting
799         fSelection.removeRange(range);
800         if (fSelection.rangeCount == 0)
801           this._removeEditorListeners(editor);
802       }
803     }
804   },
806   DidInsertText: function (aTextNode, aOffset, aString) {
807     let editor = this._getEditableNode(aTextNode).editor;
808     let controller = editor.selectionController;
809     let fSelection = controller.getSelection(Ci.nsISelectionController.SELECTION_FIND);
810     let range = this._findRange(fSelection, aTextNode, aOffset);
812     if (range) {
813       // If the text was inserted before the highlight
814       // adjust the highlight's bounds accordingly
815       if (aTextNode == range.startContainer &&
816           aOffset == range.startOffset) {
817         range.setStart(range.startContainer,
818                        range.startOffset+aString.length);
819       } else if (aTextNode != range.endContainer ||
820                  aOffset != range.endOffset) {
821         // The edit occurred within the highlight - any addition of text
822         // will result in the text no longer being a match,
823         // so remove the highlighting
824         fSelection.removeRange(range);
825         if (fSelection.rangeCount == 0)
826           this._removeEditorListeners(editor);
827       }
828     }
829   },
831   WillDeleteSelection: function (aSelection) {
832     let editor = this._getEditableNode(aSelection.getRangeAt(0)
833                                                  .startContainer).editor;
834     let controller = editor.selectionController;
835     let fSelection = controller.getSelection(Ci.nsISelectionController.SELECTION_FIND);
837     let selectionIndex = 0;
838     let findSelectionIndex = 0;
839     let shouldDelete = {};
840     let numberOfDeletedSelections = 0;
841     let numberOfMatches = fSelection.rangeCount;
843     // We need to test if any ranges in the deleted selection (aSelection)
844     // are in any of the ranges of the find selection
845     // Usually both selections will only contain one range, however
846     // either may contain more than one.
848     for (let fIndex = 0; fIndex < numberOfMatches; fIndex++) {
849       shouldDelete[fIndex] = false;
850       let fRange = fSelection.getRangeAt(fIndex);
852       for (let index = 0; index < aSelection.rangeCount; index++) {
853         if (shouldDelete[fIndex])
854           continue;
856         let selRange = aSelection.getRangeAt(index);
857         let doesOverlap = this._checkOverlap(selRange, fRange);
858         if (doesOverlap) {
859           shouldDelete[fIndex] = true;
860           numberOfDeletedSelections++;
861         }
862       }
863     }
865     // OK, so now we know what matches (if any) are in the selection
866     // that is being deleted. Time to remove them.
867     if (numberOfDeletedSelections == 0)
868       return;
870     for (let i = numberOfMatches - 1; i >= 0; i--) {
871       if (shouldDelete[i])
872         fSelection.removeRange(fSelection.getRangeAt(i));
873     }
875     // Remove listeners if no more highlights left
876     if (fSelection.rangeCount == 0)
877       this._removeEditorListeners(editor);
878   },
880   /*
881    * nsIDocumentStateListener logic follows
882    *
883    * When attaching nsIEditActionListeners, there are no guarantees
884    * as to whether the findbar or the documents in the browser will get
885    * destructed first. This leads to the potential to either leak, or to
886    * hold on to a reference an editable element's editor for too long,
887    * preventing it from being destructed.
888    *
889    * However, when an editor's owning node is being destroyed, the editor
890    * sends out a DocumentWillBeDestroyed notification. We can use this to
891    * clean up our references to the object, to allow it to be destroyed in a
892    * timely fashion.
893    */
895   /*
896    * Unhook ourselves when one of our state listeners has been called.
897    * This can happen in 4 cases:
898    *  1) The document the editor belongs to is navigated away from, and
899    *     the document is not being cached
900    *
901    *  2) The document the editor belongs to is expired from the cache
902    *
903    *  3) The tab containing the owning document is closed
904    *
905    *  4) The <input> or <textarea> that owns the editor is explicitly
906    *     removed from the DOM
907    *
908    * @param the listener that was invoked
909    */
910   _onEditorDestruction: function (aListener) {
911     // First find the index of the editor the given listener listens to.
912     // The listeners and editors arrays must always be in sync.
913     // The listener will be in our array of cached listeners, as this
914     // method could not have been called otherwise.
915     let idx = 0;
916     while (this._stateListeners[idx] != aListener)
917       idx++;
919     // Unhook both listeners
920     this._unhookListenersAtIndex(idx);
921   },
923   /*
924    * Creates a unique document state listener for an editor.
925    *
926    * It is not possible to simply have the findbar implement the
927    * listener interface itself, as it wouldn't have sufficient information
928    * to work out which editor was being destroyed. Therefore, we create new
929    * listeners on the fly, and cache them in sync with the editors they
930    * listen to.
931    */
932   _createStateListener: function () {
933     return {
934       findbar: this,
936       QueryInterface: function(aIID) {
937         if (aIID.equals(Ci.nsIDocumentStateListener) ||
938             aIID.equals(Ci.nsISupports))
939           return this;
941         throw Components.results.NS_ERROR_NO_INTERFACE;
942       },
944       NotifyDocumentWillBeDestroyed: function() {
945         this.findbar._onEditorDestruction(this);
946       },
948       // Unimplemented
949       notifyDocumentCreated: function() {},
950       notifyDocumentStateChanged: function(aDirty) {}
951     };
952   },
954   QueryInterface: XPCOMUtils.generateQI([Ci.nsIWebProgressListener,
955                                          Ci.nsISupportsWeakReference])