Wed, 31 Dec 2014 06:09:35 +0100
Cloned upstream origin tor-browser at tor-browser-31.3.0esr-4.5-1-build1
revision ID fc1c9ff7c1b2defdbc039f12214767608f46423f for hacking purpose.
michael@0 | 1 | // This Source Code Form is subject to the terms of the Mozilla Public |
michael@0 | 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this |
michael@0 | 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. |
michael@0 | 4 | |
michael@0 | 5 | this.EXPORTED_SYMBOLS = ["Finder"]; |
michael@0 | 6 | |
michael@0 | 7 | const Ci = Components.interfaces; |
michael@0 | 8 | const Cc = Components.classes; |
michael@0 | 9 | const Cu = Components.utils; |
michael@0 | 10 | |
michael@0 | 11 | Cu.import("resource://gre/modules/XPCOMUtils.jsm"); |
michael@0 | 12 | Cu.import("resource://gre/modules/Geometry.jsm"); |
michael@0 | 13 | Cu.import("resource://gre/modules/Services.jsm"); |
michael@0 | 14 | |
michael@0 | 15 | XPCOMUtils.defineLazyServiceGetter(this, "TextToSubURIService", |
michael@0 | 16 | "@mozilla.org/intl/texttosuburi;1", |
michael@0 | 17 | "nsITextToSubURI"); |
michael@0 | 18 | XPCOMUtils.defineLazyServiceGetter(this, "Clipboard", |
michael@0 | 19 | "@mozilla.org/widget/clipboard;1", |
michael@0 | 20 | "nsIClipboard"); |
michael@0 | 21 | XPCOMUtils.defineLazyServiceGetter(this, "ClipboardHelper", |
michael@0 | 22 | "@mozilla.org/widget/clipboardhelper;1", |
michael@0 | 23 | "nsIClipboardHelper"); |
michael@0 | 24 | |
michael@0 | 25 | function Finder(docShell) { |
michael@0 | 26 | this._fastFind = Cc["@mozilla.org/typeaheadfind;1"].createInstance(Ci.nsITypeAheadFind); |
michael@0 | 27 | this._fastFind.init(docShell); |
michael@0 | 28 | |
michael@0 | 29 | this._docShell = docShell; |
michael@0 | 30 | this._listeners = []; |
michael@0 | 31 | this._previousLink = null; |
michael@0 | 32 | this._searchString = null; |
michael@0 | 33 | |
michael@0 | 34 | docShell.QueryInterface(Ci.nsIInterfaceRequestor) |
michael@0 | 35 | .getInterface(Ci.nsIWebProgress) |
michael@0 | 36 | .addProgressListener(this, Ci.nsIWebProgress.NOTIFY_LOCATION); |
michael@0 | 37 | } |
michael@0 | 38 | |
michael@0 | 39 | Finder.prototype = { |
michael@0 | 40 | addResultListener: function (aListener) { |
michael@0 | 41 | if (this._listeners.indexOf(aListener) === -1) |
michael@0 | 42 | this._listeners.push(aListener); |
michael@0 | 43 | }, |
michael@0 | 44 | |
michael@0 | 45 | removeResultListener: function (aListener) { |
michael@0 | 46 | this._listeners = this._listeners.filter(l => l != aListener); |
michael@0 | 47 | }, |
michael@0 | 48 | |
michael@0 | 49 | _notify: function (aSearchString, aResult, aFindBackwards, aDrawOutline, aStoreResult = true) { |
michael@0 | 50 | if (aStoreResult) { |
michael@0 | 51 | this._searchString = aSearchString; |
michael@0 | 52 | this.clipboardSearchString = aSearchString |
michael@0 | 53 | } |
michael@0 | 54 | this._outlineLink(aDrawOutline); |
michael@0 | 55 | |
michael@0 | 56 | let foundLink = this._fastFind.foundLink; |
michael@0 | 57 | let linkURL = null; |
michael@0 | 58 | if (foundLink) { |
michael@0 | 59 | let docCharset = null; |
michael@0 | 60 | let ownerDoc = foundLink.ownerDocument; |
michael@0 | 61 | if (ownerDoc) |
michael@0 | 62 | docCharset = ownerDoc.characterSet; |
michael@0 | 63 | |
michael@0 | 64 | linkURL = TextToSubURIService.unEscapeURIForUI(docCharset, foundLink.href); |
michael@0 | 65 | } |
michael@0 | 66 | |
michael@0 | 67 | let data = { |
michael@0 | 68 | result: aResult, |
michael@0 | 69 | findBackwards: aFindBackwards, |
michael@0 | 70 | linkURL: linkURL, |
michael@0 | 71 | rect: this._getResultRect(), |
michael@0 | 72 | searchString: this._searchString, |
michael@0 | 73 | storeResult: aStoreResult |
michael@0 | 74 | }; |
michael@0 | 75 | |
michael@0 | 76 | for (let l of this._listeners) { |
michael@0 | 77 | l.onFindResult(data); |
michael@0 | 78 | } |
michael@0 | 79 | }, |
michael@0 | 80 | |
michael@0 | 81 | get searchString() { |
michael@0 | 82 | if (!this._searchString && this._fastFind.searchString) |
michael@0 | 83 | this._searchString = this._fastFind.searchString; |
michael@0 | 84 | return this._searchString; |
michael@0 | 85 | }, |
michael@0 | 86 | |
michael@0 | 87 | get clipboardSearchString() { |
michael@0 | 88 | let searchString = ""; |
michael@0 | 89 | if (!Clipboard.supportsFindClipboard()) |
michael@0 | 90 | return searchString; |
michael@0 | 91 | |
michael@0 | 92 | try { |
michael@0 | 93 | let trans = Cc["@mozilla.org/widget/transferable;1"] |
michael@0 | 94 | .createInstance(Ci.nsITransferable); |
michael@0 | 95 | trans.init(this._getWindow() |
michael@0 | 96 | .QueryInterface(Ci.nsIInterfaceRequestor) |
michael@0 | 97 | .getInterface(Ci.nsIWebNavigation) |
michael@0 | 98 | .QueryInterface(Ci.nsILoadContext)); |
michael@0 | 99 | trans.addDataFlavor("text/unicode"); |
michael@0 | 100 | |
michael@0 | 101 | Clipboard.getData(trans, Ci.nsIClipboard.kFindClipboard); |
michael@0 | 102 | |
michael@0 | 103 | let data = {}; |
michael@0 | 104 | let dataLen = {}; |
michael@0 | 105 | trans.getTransferData("text/unicode", data, dataLen); |
michael@0 | 106 | if (data.value) { |
michael@0 | 107 | data = data.value.QueryInterface(Ci.nsISupportsString); |
michael@0 | 108 | searchString = data.toString(); |
michael@0 | 109 | } |
michael@0 | 110 | } catch (ex) {} |
michael@0 | 111 | |
michael@0 | 112 | return searchString; |
michael@0 | 113 | }, |
michael@0 | 114 | |
michael@0 | 115 | set clipboardSearchString(aSearchString) { |
michael@0 | 116 | if (!aSearchString || !Clipboard.supportsFindClipboard()) |
michael@0 | 117 | return; |
michael@0 | 118 | |
michael@0 | 119 | ClipboardHelper.copyStringToClipboard(aSearchString, |
michael@0 | 120 | Ci.nsIClipboard.kFindClipboard, |
michael@0 | 121 | this._getWindow().document); |
michael@0 | 122 | }, |
michael@0 | 123 | |
michael@0 | 124 | set caseSensitive(aSensitive) { |
michael@0 | 125 | this._fastFind.caseSensitive = aSensitive; |
michael@0 | 126 | }, |
michael@0 | 127 | |
michael@0 | 128 | /** |
michael@0 | 129 | * Used for normal search operations, highlights the first match. |
michael@0 | 130 | * |
michael@0 | 131 | * @param aSearchString String to search for. |
michael@0 | 132 | * @param aLinksOnly Only consider nodes that are links for the search. |
michael@0 | 133 | * @param aDrawOutline Puts an outline around matched links. |
michael@0 | 134 | */ |
michael@0 | 135 | fastFind: function (aSearchString, aLinksOnly, aDrawOutline) { |
michael@0 | 136 | let result = this._fastFind.find(aSearchString, aLinksOnly); |
michael@0 | 137 | let searchString = this._fastFind.searchString; |
michael@0 | 138 | this._notify(searchString, result, false, aDrawOutline); |
michael@0 | 139 | }, |
michael@0 | 140 | |
michael@0 | 141 | /** |
michael@0 | 142 | * Repeat the previous search. Should only be called after a previous |
michael@0 | 143 | * call to Finder.fastFind. |
michael@0 | 144 | * |
michael@0 | 145 | * @param aFindBackwards Controls the search direction: |
michael@0 | 146 | * true: before current match, false: after current match. |
michael@0 | 147 | * @param aLinksOnly Only consider nodes that are links for the search. |
michael@0 | 148 | * @param aDrawOutline Puts an outline around matched links. |
michael@0 | 149 | */ |
michael@0 | 150 | findAgain: function (aFindBackwards, aLinksOnly, aDrawOutline) { |
michael@0 | 151 | let result = this._fastFind.findAgain(aFindBackwards, aLinksOnly); |
michael@0 | 152 | let searchString = this._fastFind.searchString; |
michael@0 | 153 | this._notify(searchString, result, aFindBackwards, aDrawOutline); |
michael@0 | 154 | }, |
michael@0 | 155 | |
michael@0 | 156 | /** |
michael@0 | 157 | * Forcibly set the search string of the find clipboard to the currently |
michael@0 | 158 | * selected text in the window, on supported platforms (i.e. OSX). |
michael@0 | 159 | */ |
michael@0 | 160 | setSearchStringToSelection: function() { |
michael@0 | 161 | // Find the selected text. |
michael@0 | 162 | let selection = this._getWindow().getSelection(); |
michael@0 | 163 | // Don't go for empty selections. |
michael@0 | 164 | if (!selection.rangeCount) |
michael@0 | 165 | return null; |
michael@0 | 166 | let searchString = (selection.toString() || "").trim(); |
michael@0 | 167 | // Empty strings are rather useless to search for. |
michael@0 | 168 | if (!searchString.length) |
michael@0 | 169 | return null; |
michael@0 | 170 | |
michael@0 | 171 | this.clipboardSearchString = searchString; |
michael@0 | 172 | return searchString; |
michael@0 | 173 | }, |
michael@0 | 174 | |
michael@0 | 175 | highlight: function (aHighlight, aWord) { |
michael@0 | 176 | let found = this._highlight(aHighlight, aWord, null); |
michael@0 | 177 | if (aHighlight) { |
michael@0 | 178 | let result = found ? Ci.nsITypeAheadFind.FIND_FOUND |
michael@0 | 179 | : Ci.nsITypeAheadFind.FIND_NOTFOUND; |
michael@0 | 180 | this._notify(aWord, result, false, false, false); |
michael@0 | 181 | } |
michael@0 | 182 | }, |
michael@0 | 183 | |
michael@0 | 184 | enableSelection: function() { |
michael@0 | 185 | this._fastFind.setSelectionModeAndRepaint(Ci.nsISelectionController.SELECTION_ON); |
michael@0 | 186 | this._restoreOriginalOutline(); |
michael@0 | 187 | }, |
michael@0 | 188 | |
michael@0 | 189 | removeSelection: function() { |
michael@0 | 190 | this._fastFind.collapseSelection(); |
michael@0 | 191 | this.enableSelection(); |
michael@0 | 192 | }, |
michael@0 | 193 | |
michael@0 | 194 | focusContent: function() { |
michael@0 | 195 | // Allow Finder listeners to cancel focusing the content. |
michael@0 | 196 | for (let l of this._listeners) { |
michael@0 | 197 | if (!l.shouldFocusContent()) |
michael@0 | 198 | return; |
michael@0 | 199 | } |
michael@0 | 200 | |
michael@0 | 201 | let fastFind = this._fastFind; |
michael@0 | 202 | const fm = Cc["@mozilla.org/focus-manager;1"].getService(Ci.nsIFocusManager); |
michael@0 | 203 | try { |
michael@0 | 204 | // Try to find the best possible match that should receive focus and |
michael@0 | 205 | // block scrolling on focus since find already scrolls. Further |
michael@0 | 206 | // scrolling is due to user action, so don't override this. |
michael@0 | 207 | if (fastFind.foundLink) { |
michael@0 | 208 | fm.setFocus(fastFind.foundLink, fm.FLAG_NOSCROLL); |
michael@0 | 209 | } else if (fastFind.foundEditable) { |
michael@0 | 210 | fm.setFocus(fastFind.foundEditable, fm.FLAG_NOSCROLL); |
michael@0 | 211 | fastFind.collapseSelection(); |
michael@0 | 212 | } else { |
michael@0 | 213 | this._getWindow().focus() |
michael@0 | 214 | } |
michael@0 | 215 | } catch (e) {} |
michael@0 | 216 | }, |
michael@0 | 217 | |
michael@0 | 218 | keyPress: function (aEvent) { |
michael@0 | 219 | let controller = this._getSelectionController(this._getWindow()); |
michael@0 | 220 | |
michael@0 | 221 | switch (aEvent.keyCode) { |
michael@0 | 222 | case Ci.nsIDOMKeyEvent.DOM_VK_RETURN: |
michael@0 | 223 | if (this._fastFind.foundLink) { |
michael@0 | 224 | let view = this._fastFind.foundLink.ownerDocument.defaultView; |
michael@0 | 225 | this._fastFind.foundLink.dispatchEvent(new view.MouseEvent("click", { |
michael@0 | 226 | view: view, |
michael@0 | 227 | cancelable: true, |
michael@0 | 228 | bubbles: true, |
michael@0 | 229 | ctrlKey: aEvent.ctrlKey, |
michael@0 | 230 | altKey: aEvent.altKey, |
michael@0 | 231 | shiftKey: aEvent.shiftKey, |
michael@0 | 232 | metaKey: aEvent.metaKey |
michael@0 | 233 | })); |
michael@0 | 234 | } |
michael@0 | 235 | break; |
michael@0 | 236 | case Ci.nsIDOMKeyEvent.DOM_VK_TAB: |
michael@0 | 237 | let direction = Services.focus.MOVEFOCUS_FORWARD; |
michael@0 | 238 | if (aEvent.shiftKey) { |
michael@0 | 239 | direction = Services.focus.MOVEFOCUS_BACKWARD; |
michael@0 | 240 | } |
michael@0 | 241 | Services.focus.moveFocus(this._getWindow(), null, direction, 0); |
michael@0 | 242 | break; |
michael@0 | 243 | case Ci.nsIDOMKeyEvent.DOM_VK_PAGE_UP: |
michael@0 | 244 | controller.scrollPage(false); |
michael@0 | 245 | break; |
michael@0 | 246 | case Ci.nsIDOMKeyEvent.DOM_VK_PAGE_DOWN: |
michael@0 | 247 | controller.scrollPage(true); |
michael@0 | 248 | break; |
michael@0 | 249 | case Ci.nsIDOMKeyEvent.DOM_VK_UP: |
michael@0 | 250 | controller.scrollLine(false); |
michael@0 | 251 | break; |
michael@0 | 252 | case Ci.nsIDOMKeyEvent.DOM_VK_DOWN: |
michael@0 | 253 | controller.scrollLine(true); |
michael@0 | 254 | break; |
michael@0 | 255 | } |
michael@0 | 256 | }, |
michael@0 | 257 | |
michael@0 | 258 | _getWindow: function () { |
michael@0 | 259 | return this._docShell.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindow); |
michael@0 | 260 | }, |
michael@0 | 261 | |
michael@0 | 262 | /** |
michael@0 | 263 | * Get the bounding selection rect in CSS px relative to the origin of the |
michael@0 | 264 | * top-level content document. |
michael@0 | 265 | */ |
michael@0 | 266 | _getResultRect: function () { |
michael@0 | 267 | let topWin = this._getWindow(); |
michael@0 | 268 | let win = this._fastFind.currentWindow; |
michael@0 | 269 | if (!win) |
michael@0 | 270 | return null; |
michael@0 | 271 | |
michael@0 | 272 | let selection = win.getSelection(); |
michael@0 | 273 | if (!selection.rangeCount || selection.isCollapsed) { |
michael@0 | 274 | // The selection can be into an input or a textarea element. |
michael@0 | 275 | let nodes = win.document.querySelectorAll("input, textarea"); |
michael@0 | 276 | for (let node of nodes) { |
michael@0 | 277 | if (node instanceof Ci.nsIDOMNSEditableElement && node.editor) { |
michael@0 | 278 | let sc = node.editor.selectionController; |
michael@0 | 279 | selection = sc.getSelection(Ci.nsISelectionController.SELECTION_NORMAL); |
michael@0 | 280 | if (selection.rangeCount && !selection.isCollapsed) { |
michael@0 | 281 | break; |
michael@0 | 282 | } |
michael@0 | 283 | } |
michael@0 | 284 | } |
michael@0 | 285 | } |
michael@0 | 286 | |
michael@0 | 287 | let utils = topWin.QueryInterface(Ci.nsIInterfaceRequestor) |
michael@0 | 288 | .getInterface(Ci.nsIDOMWindowUtils); |
michael@0 | 289 | |
michael@0 | 290 | let scrollX = {}, scrollY = {}; |
michael@0 | 291 | utils.getScrollXY(false, scrollX, scrollY); |
michael@0 | 292 | |
michael@0 | 293 | for (let frame = win; frame != topWin; frame = frame.parent) { |
michael@0 | 294 | let rect = frame.frameElement.getBoundingClientRect(); |
michael@0 | 295 | let left = frame.getComputedStyle(frame.frameElement, "").borderLeftWidth; |
michael@0 | 296 | let top = frame.getComputedStyle(frame.frameElement, "").borderTopWidth; |
michael@0 | 297 | scrollX.value += rect.left + parseInt(left, 10); |
michael@0 | 298 | scrollY.value += rect.top + parseInt(top, 10); |
michael@0 | 299 | } |
michael@0 | 300 | let rect = Rect.fromRect(selection.getRangeAt(0).getBoundingClientRect()); |
michael@0 | 301 | return rect.translate(scrollX.value, scrollY.value); |
michael@0 | 302 | }, |
michael@0 | 303 | |
michael@0 | 304 | _outlineLink: function (aDrawOutline) { |
michael@0 | 305 | let foundLink = this._fastFind.foundLink; |
michael@0 | 306 | |
michael@0 | 307 | // Optimization: We are drawing outlines and we matched |
michael@0 | 308 | // the same link before, so don't duplicate work. |
michael@0 | 309 | if (foundLink == this._previousLink && aDrawOutline) |
michael@0 | 310 | return; |
michael@0 | 311 | |
michael@0 | 312 | this._restoreOriginalOutline(); |
michael@0 | 313 | |
michael@0 | 314 | if (foundLink && aDrawOutline) { |
michael@0 | 315 | // Backup original outline |
michael@0 | 316 | this._tmpOutline = foundLink.style.outline; |
michael@0 | 317 | this._tmpOutlineOffset = foundLink.style.outlineOffset; |
michael@0 | 318 | |
michael@0 | 319 | // Draw pseudo focus rect |
michael@0 | 320 | // XXX Should we change the following style for FAYT pseudo focus? |
michael@0 | 321 | // XXX Shouldn't we change default design if outline is visible |
michael@0 | 322 | // already? |
michael@0 | 323 | // Don't set the outline-color, we should always use initial value. |
michael@0 | 324 | foundLink.style.outline = "1px dotted"; |
michael@0 | 325 | foundLink.style.outlineOffset = "0"; |
michael@0 | 326 | |
michael@0 | 327 | this._previousLink = foundLink; |
michael@0 | 328 | } |
michael@0 | 329 | }, |
michael@0 | 330 | |
michael@0 | 331 | _restoreOriginalOutline: function () { |
michael@0 | 332 | // Removes the outline around the last found link. |
michael@0 | 333 | if (this._previousLink) { |
michael@0 | 334 | this._previousLink.style.outline = this._tmpOutline; |
michael@0 | 335 | this._previousLink.style.outlineOffset = this._tmpOutlineOffset; |
michael@0 | 336 | this._previousLink = null; |
michael@0 | 337 | } |
michael@0 | 338 | }, |
michael@0 | 339 | |
michael@0 | 340 | _highlight: function (aHighlight, aWord, aWindow) { |
michael@0 | 341 | let win = aWindow || this._getWindow(); |
michael@0 | 342 | |
michael@0 | 343 | let found = false; |
michael@0 | 344 | for (let i = 0; win.frames && i < win.frames.length; i++) { |
michael@0 | 345 | if (this._highlight(aHighlight, aWord, win.frames[i])) |
michael@0 | 346 | found = true; |
michael@0 | 347 | } |
michael@0 | 348 | |
michael@0 | 349 | let controller = this._getSelectionController(win); |
michael@0 | 350 | let doc = win.document; |
michael@0 | 351 | if (!controller || !doc || !doc.documentElement) { |
michael@0 | 352 | // Without the selection controller, |
michael@0 | 353 | // we are unable to (un)highlight any matches |
michael@0 | 354 | return found; |
michael@0 | 355 | } |
michael@0 | 356 | |
michael@0 | 357 | let body = (doc instanceof Ci.nsIDOMHTMLDocument && doc.body) ? |
michael@0 | 358 | doc.body : doc.documentElement; |
michael@0 | 359 | |
michael@0 | 360 | if (aHighlight) { |
michael@0 | 361 | let searchRange = doc.createRange(); |
michael@0 | 362 | searchRange.selectNodeContents(body); |
michael@0 | 363 | |
michael@0 | 364 | let startPt = searchRange.cloneRange(); |
michael@0 | 365 | startPt.collapse(true); |
michael@0 | 366 | |
michael@0 | 367 | let endPt = searchRange.cloneRange(); |
michael@0 | 368 | endPt.collapse(false); |
michael@0 | 369 | |
michael@0 | 370 | let retRange = null; |
michael@0 | 371 | let finder = Cc["@mozilla.org/embedcomp/rangefind;1"] |
michael@0 | 372 | .createInstance() |
michael@0 | 373 | .QueryInterface(Ci.nsIFind); |
michael@0 | 374 | |
michael@0 | 375 | finder.caseSensitive = this._fastFind.caseSensitive; |
michael@0 | 376 | |
michael@0 | 377 | while ((retRange = finder.Find(aWord, searchRange, |
michael@0 | 378 | startPt, endPt))) { |
michael@0 | 379 | this._highlightRange(retRange, controller); |
michael@0 | 380 | startPt = retRange.cloneRange(); |
michael@0 | 381 | startPt.collapse(false); |
michael@0 | 382 | |
michael@0 | 383 | found = true; |
michael@0 | 384 | } |
michael@0 | 385 | } else { |
michael@0 | 386 | // First, attempt to remove highlighting from main document |
michael@0 | 387 | let sel = controller.getSelection(Ci.nsISelectionController.SELECTION_FIND); |
michael@0 | 388 | sel.removeAllRanges(); |
michael@0 | 389 | |
michael@0 | 390 | // Next, check our editor cache, for editors belonging to this |
michael@0 | 391 | // document |
michael@0 | 392 | if (this._editors) { |
michael@0 | 393 | for (let x = this._editors.length - 1; x >= 0; --x) { |
michael@0 | 394 | if (this._editors[x].document == doc) { |
michael@0 | 395 | sel = this._editors[x].selectionController |
michael@0 | 396 | .getSelection(Ci.nsISelectionController.SELECTION_FIND); |
michael@0 | 397 | sel.removeAllRanges(); |
michael@0 | 398 | // We don't need to listen to this editor any more |
michael@0 | 399 | this._unhookListenersAtIndex(x); |
michael@0 | 400 | } |
michael@0 | 401 | } |
michael@0 | 402 | } |
michael@0 | 403 | |
michael@0 | 404 | // Removing the highlighting always succeeds, so return true. |
michael@0 | 405 | found = true; |
michael@0 | 406 | } |
michael@0 | 407 | |
michael@0 | 408 | return found; |
michael@0 | 409 | }, |
michael@0 | 410 | |
michael@0 | 411 | _highlightRange: function(aRange, aController) { |
michael@0 | 412 | let node = aRange.startContainer; |
michael@0 | 413 | let controller = aController; |
michael@0 | 414 | |
michael@0 | 415 | let editableNode = this._getEditableNode(node); |
michael@0 | 416 | if (editableNode) |
michael@0 | 417 | controller = editableNode.editor.selectionController; |
michael@0 | 418 | |
michael@0 | 419 | let findSelection = controller.getSelection(Ci.nsISelectionController.SELECTION_FIND); |
michael@0 | 420 | findSelection.addRange(aRange); |
michael@0 | 421 | |
michael@0 | 422 | if (editableNode) { |
michael@0 | 423 | // Highlighting added, so cache this editor, and hook up listeners |
michael@0 | 424 | // to ensure we deal properly with edits within the highlighting |
michael@0 | 425 | if (!this._editors) { |
michael@0 | 426 | this._editors = []; |
michael@0 | 427 | this._stateListeners = []; |
michael@0 | 428 | } |
michael@0 | 429 | |
michael@0 | 430 | let existingIndex = this._editors.indexOf(editableNode.editor); |
michael@0 | 431 | if (existingIndex == -1) { |
michael@0 | 432 | let x = this._editors.length; |
michael@0 | 433 | this._editors[x] = editableNode.editor; |
michael@0 | 434 | this._stateListeners[x] = this._createStateListener(); |
michael@0 | 435 | this._editors[x].addEditActionListener(this); |
michael@0 | 436 | this._editors[x].addDocumentStateListener(this._stateListeners[x]); |
michael@0 | 437 | } |
michael@0 | 438 | } |
michael@0 | 439 | }, |
michael@0 | 440 | |
michael@0 | 441 | _getSelectionController: function(aWindow) { |
michael@0 | 442 | // display: none iframes don't have a selection controller, see bug 493658 |
michael@0 | 443 | if (!aWindow.innerWidth || !aWindow.innerHeight) |
michael@0 | 444 | return null; |
michael@0 | 445 | |
michael@0 | 446 | // Yuck. See bug 138068. |
michael@0 | 447 | let docShell = aWindow.QueryInterface(Ci.nsIInterfaceRequestor) |
michael@0 | 448 | .getInterface(Ci.nsIWebNavigation) |
michael@0 | 449 | .QueryInterface(Ci.nsIDocShell); |
michael@0 | 450 | |
michael@0 | 451 | let controller = docShell.QueryInterface(Ci.nsIInterfaceRequestor) |
michael@0 | 452 | .getInterface(Ci.nsISelectionDisplay) |
michael@0 | 453 | .QueryInterface(Ci.nsISelectionController); |
michael@0 | 454 | return controller; |
michael@0 | 455 | }, |
michael@0 | 456 | |
michael@0 | 457 | /* |
michael@0 | 458 | * For a given node, walk up it's parent chain, to try and find an |
michael@0 | 459 | * editable node. |
michael@0 | 460 | * |
michael@0 | 461 | * @param aNode the node we want to check |
michael@0 | 462 | * @returns the first node in the parent chain that is editable, |
michael@0 | 463 | * null if there is no such node |
michael@0 | 464 | */ |
michael@0 | 465 | _getEditableNode: function (aNode) { |
michael@0 | 466 | while (aNode) { |
michael@0 | 467 | if (aNode instanceof Ci.nsIDOMNSEditableElement) |
michael@0 | 468 | return aNode.editor ? aNode : null; |
michael@0 | 469 | |
michael@0 | 470 | aNode = aNode.parentNode; |
michael@0 | 471 | } |
michael@0 | 472 | return null; |
michael@0 | 473 | }, |
michael@0 | 474 | |
michael@0 | 475 | /* |
michael@0 | 476 | * Helper method to unhook listeners, remove cached editors |
michael@0 | 477 | * and keep the relevant arrays in sync |
michael@0 | 478 | * |
michael@0 | 479 | * @param aIndex the index into the array of editors/state listeners |
michael@0 | 480 | * we wish to remove |
michael@0 | 481 | */ |
michael@0 | 482 | _unhookListenersAtIndex: function (aIndex) { |
michael@0 | 483 | this._editors[aIndex].removeEditActionListener(this); |
michael@0 | 484 | this._editors[aIndex] |
michael@0 | 485 | .removeDocumentStateListener(this._stateListeners[aIndex]); |
michael@0 | 486 | this._editors.splice(aIndex, 1); |
michael@0 | 487 | this._stateListeners.splice(aIndex, 1); |
michael@0 | 488 | if (!this._editors.length) { |
michael@0 | 489 | delete this._editors; |
michael@0 | 490 | delete this._stateListeners; |
michael@0 | 491 | } |
michael@0 | 492 | }, |
michael@0 | 493 | |
michael@0 | 494 | /* |
michael@0 | 495 | * Remove ourselves as an nsIEditActionListener and |
michael@0 | 496 | * nsIDocumentStateListener from a given cached editor |
michael@0 | 497 | * |
michael@0 | 498 | * @param aEditor the editor we no longer wish to listen to |
michael@0 | 499 | */ |
michael@0 | 500 | _removeEditorListeners: function (aEditor) { |
michael@0 | 501 | // aEditor is an editor that we listen to, so therefore must be |
michael@0 | 502 | // cached. Find the index of this editor |
michael@0 | 503 | let idx = this._editors.indexOf(aEditor); |
michael@0 | 504 | if (idx == -1) |
michael@0 | 505 | return; |
michael@0 | 506 | // Now unhook ourselves, and remove our cached copy |
michael@0 | 507 | this._unhookListenersAtIndex(idx); |
michael@0 | 508 | }, |
michael@0 | 509 | |
michael@0 | 510 | /* |
michael@0 | 511 | * nsIEditActionListener logic follows |
michael@0 | 512 | * |
michael@0 | 513 | * We implement this interface to allow us to catch the case where |
michael@0 | 514 | * the findbar found a match in a HTML <input> or <textarea>. If the |
michael@0 | 515 | * user adjusts the text in some way, it will no longer match, so we |
michael@0 | 516 | * want to remove the highlight, rather than have it expand/contract |
michael@0 | 517 | * when letters are added or removed. |
michael@0 | 518 | */ |
michael@0 | 519 | |
michael@0 | 520 | /* |
michael@0 | 521 | * Helper method used to check whether a selection intersects with |
michael@0 | 522 | * some highlighting |
michael@0 | 523 | * |
michael@0 | 524 | * @param aSelectionRange the range from the selection to check |
michael@0 | 525 | * @param aFindRange the highlighted range to check against |
michael@0 | 526 | * @returns true if they intersect, false otherwise |
michael@0 | 527 | */ |
michael@0 | 528 | _checkOverlap: function (aSelectionRange, aFindRange) { |
michael@0 | 529 | // The ranges overlap if one of the following is true: |
michael@0 | 530 | // 1) At least one of the endpoints of the deleted selection |
michael@0 | 531 | // is in the find selection |
michael@0 | 532 | // 2) At least one of the endpoints of the find selection |
michael@0 | 533 | // is in the deleted selection |
michael@0 | 534 | if (aFindRange.isPointInRange(aSelectionRange.startContainer, |
michael@0 | 535 | aSelectionRange.startOffset)) |
michael@0 | 536 | return true; |
michael@0 | 537 | if (aFindRange.isPointInRange(aSelectionRange.endContainer, |
michael@0 | 538 | aSelectionRange.endOffset)) |
michael@0 | 539 | return true; |
michael@0 | 540 | if (aSelectionRange.isPointInRange(aFindRange.startContainer, |
michael@0 | 541 | aFindRange.startOffset)) |
michael@0 | 542 | return true; |
michael@0 | 543 | if (aSelectionRange.isPointInRange(aFindRange.endContainer, |
michael@0 | 544 | aFindRange.endOffset)) |
michael@0 | 545 | return true; |
michael@0 | 546 | |
michael@0 | 547 | return false; |
michael@0 | 548 | }, |
michael@0 | 549 | |
michael@0 | 550 | /* |
michael@0 | 551 | * Helper method to determine if an edit occurred within a highlight |
michael@0 | 552 | * |
michael@0 | 553 | * @param aSelection the selection we wish to check |
michael@0 | 554 | * @param aNode the node we want to check is contained in aSelection |
michael@0 | 555 | * @param aOffset the offset into aNode that we want to check |
michael@0 | 556 | * @returns the range containing (aNode, aOffset) or null if no ranges |
michael@0 | 557 | * in the selection contain it |
michael@0 | 558 | */ |
michael@0 | 559 | _findRange: function (aSelection, aNode, aOffset) { |
michael@0 | 560 | let rangeCount = aSelection.rangeCount; |
michael@0 | 561 | let rangeidx = 0; |
michael@0 | 562 | let foundContainingRange = false; |
michael@0 | 563 | let range = null; |
michael@0 | 564 | |
michael@0 | 565 | // Check to see if this node is inside one of the selection's ranges |
michael@0 | 566 | while (!foundContainingRange && rangeidx < rangeCount) { |
michael@0 | 567 | range = aSelection.getRangeAt(rangeidx); |
michael@0 | 568 | if (range.isPointInRange(aNode, aOffset)) { |
michael@0 | 569 | foundContainingRange = true; |
michael@0 | 570 | break; |
michael@0 | 571 | } |
michael@0 | 572 | rangeidx++; |
michael@0 | 573 | } |
michael@0 | 574 | |
michael@0 | 575 | if (foundContainingRange) |
michael@0 | 576 | return range; |
michael@0 | 577 | |
michael@0 | 578 | return null; |
michael@0 | 579 | }, |
michael@0 | 580 | |
michael@0 | 581 | // Start of nsIWebProgressListener implementation. |
michael@0 | 582 | |
michael@0 | 583 | onLocationChange: function(aWebProgress, aRequest, aLocation, aFlags) { |
michael@0 | 584 | if (!aWebProgress.isTopLevel) |
michael@0 | 585 | return; |
michael@0 | 586 | |
michael@0 | 587 | // Avoid leaking if we change the page. |
michael@0 | 588 | this._previousLink = null; |
michael@0 | 589 | }, |
michael@0 | 590 | |
michael@0 | 591 | // Start of nsIEditActionListener implementations |
michael@0 | 592 | |
michael@0 | 593 | WillDeleteText: function (aTextNode, aOffset, aLength) { |
michael@0 | 594 | let editor = this._getEditableNode(aTextNode).editor; |
michael@0 | 595 | let controller = editor.selectionController; |
michael@0 | 596 | let fSelection = controller.getSelection(Ci.nsISelectionController.SELECTION_FIND); |
michael@0 | 597 | let range = this._findRange(fSelection, aTextNode, aOffset); |
michael@0 | 598 | |
michael@0 | 599 | if (range) { |
michael@0 | 600 | // Don't remove the highlighting if the deleted text is at the |
michael@0 | 601 | // end of the range |
michael@0 | 602 | if (aTextNode != range.endContainer || |
michael@0 | 603 | aOffset != range.endOffset) { |
michael@0 | 604 | // Text within the highlight is being removed - the text can |
michael@0 | 605 | // no longer be a match, so remove the highlighting |
michael@0 | 606 | fSelection.removeRange(range); |
michael@0 | 607 | if (fSelection.rangeCount == 0) |
michael@0 | 608 | this._removeEditorListeners(editor); |
michael@0 | 609 | } |
michael@0 | 610 | } |
michael@0 | 611 | }, |
michael@0 | 612 | |
michael@0 | 613 | DidInsertText: function (aTextNode, aOffset, aString) { |
michael@0 | 614 | let editor = this._getEditableNode(aTextNode).editor; |
michael@0 | 615 | let controller = editor.selectionController; |
michael@0 | 616 | let fSelection = controller.getSelection(Ci.nsISelectionController.SELECTION_FIND); |
michael@0 | 617 | let range = this._findRange(fSelection, aTextNode, aOffset); |
michael@0 | 618 | |
michael@0 | 619 | if (range) { |
michael@0 | 620 | // If the text was inserted before the highlight |
michael@0 | 621 | // adjust the highlight's bounds accordingly |
michael@0 | 622 | if (aTextNode == range.startContainer && |
michael@0 | 623 | aOffset == range.startOffset) { |
michael@0 | 624 | range.setStart(range.startContainer, |
michael@0 | 625 | range.startOffset+aString.length); |
michael@0 | 626 | } else if (aTextNode != range.endContainer || |
michael@0 | 627 | aOffset != range.endOffset) { |
michael@0 | 628 | // The edit occurred within the highlight - any addition of text |
michael@0 | 629 | // will result in the text no longer being a match, |
michael@0 | 630 | // so remove the highlighting |
michael@0 | 631 | fSelection.removeRange(range); |
michael@0 | 632 | if (fSelection.rangeCount == 0) |
michael@0 | 633 | this._removeEditorListeners(editor); |
michael@0 | 634 | } |
michael@0 | 635 | } |
michael@0 | 636 | }, |
michael@0 | 637 | |
michael@0 | 638 | WillDeleteSelection: function (aSelection) { |
michael@0 | 639 | let editor = this._getEditableNode(aSelection.getRangeAt(0) |
michael@0 | 640 | .startContainer).editor; |
michael@0 | 641 | let controller = editor.selectionController; |
michael@0 | 642 | let fSelection = controller.getSelection(Ci.nsISelectionController.SELECTION_FIND); |
michael@0 | 643 | |
michael@0 | 644 | let selectionIndex = 0; |
michael@0 | 645 | let findSelectionIndex = 0; |
michael@0 | 646 | let shouldDelete = {}; |
michael@0 | 647 | let numberOfDeletedSelections = 0; |
michael@0 | 648 | let numberOfMatches = fSelection.rangeCount; |
michael@0 | 649 | |
michael@0 | 650 | // We need to test if any ranges in the deleted selection (aSelection) |
michael@0 | 651 | // are in any of the ranges of the find selection |
michael@0 | 652 | // Usually both selections will only contain one range, however |
michael@0 | 653 | // either may contain more than one. |
michael@0 | 654 | |
michael@0 | 655 | for (let fIndex = 0; fIndex < numberOfMatches; fIndex++) { |
michael@0 | 656 | shouldDelete[fIndex] = false; |
michael@0 | 657 | let fRange = fSelection.getRangeAt(fIndex); |
michael@0 | 658 | |
michael@0 | 659 | for (let index = 0; index < aSelection.rangeCount; index++) { |
michael@0 | 660 | if (shouldDelete[fIndex]) |
michael@0 | 661 | continue; |
michael@0 | 662 | |
michael@0 | 663 | let selRange = aSelection.getRangeAt(index); |
michael@0 | 664 | let doesOverlap = this._checkOverlap(selRange, fRange); |
michael@0 | 665 | if (doesOverlap) { |
michael@0 | 666 | shouldDelete[fIndex] = true; |
michael@0 | 667 | numberOfDeletedSelections++; |
michael@0 | 668 | } |
michael@0 | 669 | } |
michael@0 | 670 | } |
michael@0 | 671 | |
michael@0 | 672 | // OK, so now we know what matches (if any) are in the selection |
michael@0 | 673 | // that is being deleted. Time to remove them. |
michael@0 | 674 | if (numberOfDeletedSelections == 0) |
michael@0 | 675 | return; |
michael@0 | 676 | |
michael@0 | 677 | for (let i = numberOfMatches - 1; i >= 0; i--) { |
michael@0 | 678 | if (shouldDelete[i]) |
michael@0 | 679 | fSelection.removeRange(fSelection.getRangeAt(i)); |
michael@0 | 680 | } |
michael@0 | 681 | |
michael@0 | 682 | // Remove listeners if no more highlights left |
michael@0 | 683 | if (fSelection.rangeCount == 0) |
michael@0 | 684 | this._removeEditorListeners(editor); |
michael@0 | 685 | }, |
michael@0 | 686 | |
michael@0 | 687 | /* |
michael@0 | 688 | * nsIDocumentStateListener logic follows |
michael@0 | 689 | * |
michael@0 | 690 | * When attaching nsIEditActionListeners, there are no guarantees |
michael@0 | 691 | * as to whether the findbar or the documents in the browser will get |
michael@0 | 692 | * destructed first. This leads to the potential to either leak, or to |
michael@0 | 693 | * hold on to a reference an editable element's editor for too long, |
michael@0 | 694 | * preventing it from being destructed. |
michael@0 | 695 | * |
michael@0 | 696 | * However, when an editor's owning node is being destroyed, the editor |
michael@0 | 697 | * sends out a DocumentWillBeDestroyed notification. We can use this to |
michael@0 | 698 | * clean up our references to the object, to allow it to be destroyed in a |
michael@0 | 699 | * timely fashion. |
michael@0 | 700 | */ |
michael@0 | 701 | |
michael@0 | 702 | /* |
michael@0 | 703 | * Unhook ourselves when one of our state listeners has been called. |
michael@0 | 704 | * This can happen in 4 cases: |
michael@0 | 705 | * 1) The document the editor belongs to is navigated away from, and |
michael@0 | 706 | * the document is not being cached |
michael@0 | 707 | * |
michael@0 | 708 | * 2) The document the editor belongs to is expired from the cache |
michael@0 | 709 | * |
michael@0 | 710 | * 3) The tab containing the owning document is closed |
michael@0 | 711 | * |
michael@0 | 712 | * 4) The <input> or <textarea> that owns the editor is explicitly |
michael@0 | 713 | * removed from the DOM |
michael@0 | 714 | * |
michael@0 | 715 | * @param the listener that was invoked |
michael@0 | 716 | */ |
michael@0 | 717 | _onEditorDestruction: function (aListener) { |
michael@0 | 718 | // First find the index of the editor the given listener listens to. |
michael@0 | 719 | // The listeners and editors arrays must always be in sync. |
michael@0 | 720 | // The listener will be in our array of cached listeners, as this |
michael@0 | 721 | // method could not have been called otherwise. |
michael@0 | 722 | let idx = 0; |
michael@0 | 723 | while (this._stateListeners[idx] != aListener) |
michael@0 | 724 | idx++; |
michael@0 | 725 | |
michael@0 | 726 | // Unhook both listeners |
michael@0 | 727 | this._unhookListenersAtIndex(idx); |
michael@0 | 728 | }, |
michael@0 | 729 | |
michael@0 | 730 | /* |
michael@0 | 731 | * Creates a unique document state listener for an editor. |
michael@0 | 732 | * |
michael@0 | 733 | * It is not possible to simply have the findbar implement the |
michael@0 | 734 | * listener interface itself, as it wouldn't have sufficient information |
michael@0 | 735 | * to work out which editor was being destroyed. Therefore, we create new |
michael@0 | 736 | * listeners on the fly, and cache them in sync with the editors they |
michael@0 | 737 | * listen to. |
michael@0 | 738 | */ |
michael@0 | 739 | _createStateListener: function () { |
michael@0 | 740 | return { |
michael@0 | 741 | findbar: this, |
michael@0 | 742 | |
michael@0 | 743 | QueryInterface: function(aIID) { |
michael@0 | 744 | if (aIID.equals(Ci.nsIDocumentStateListener) || |
michael@0 | 745 | aIID.equals(Ci.nsISupports)) |
michael@0 | 746 | return this; |
michael@0 | 747 | |
michael@0 | 748 | throw Components.results.NS_ERROR_NO_INTERFACE; |
michael@0 | 749 | }, |
michael@0 | 750 | |
michael@0 | 751 | NotifyDocumentWillBeDestroyed: function() { |
michael@0 | 752 | this.findbar._onEditorDestruction(this); |
michael@0 | 753 | }, |
michael@0 | 754 | |
michael@0 | 755 | // Unimplemented |
michael@0 | 756 | notifyDocumentCreated: function() {}, |
michael@0 | 757 | notifyDocumentStateChanged: function(aDirty) {} |
michael@0 | 758 | }; |
michael@0 | 759 | }, |
michael@0 | 760 | |
michael@0 | 761 | QueryInterface: XPCOMUtils.generateQI([Ci.nsIWebProgressListener, |
michael@0 | 762 | Ci.nsISupportsWeakReference]) |
michael@0 | 763 | }; |