1 // Copyright (c) 2008-2011 by Kris Maglione <maglione.k@gmail.com>
3 // This work is licensed for reuse under an MIT license. Details are
4 // given in the LICENSE.txt file included with this file.
7 Components.utils.import("resource://dactyl/bootstrap.jsm");
8 defineModule("finder", {
9 exports: ["RangeFind", "RangeFinder", "rangefinder"],
11 use: ["messages", "services", "util"]
14 function equals(a, b) XPCNativeWrapper(a) == XPCNativeWrapper(b);
18 /** @instance rangefinder */
19 var RangeFinder = Module("rangefinder", {
20 Local: function (dactyl, modules, window) ({
23 this.modules = modules;
25 this.lastFindPattern = "";
29 let find = modules.buffer.localStore.rangeFind;
30 if (find && find.stale || !isinstance(find, RangeFind))
31 return this.rangeFind = null;
34 set rangeFind(val) modules.buffer.localStore.rangeFind = val
37 init: function init() {
38 prefs.safeSet("accessibility.typeaheadfind.autostart", false);
39 // The above should be sufficient, but: http://dactyl.sf.net/bmo/348187
40 prefs.safeSet("accessibility.typeaheadfind", false);
43 get commandline() this.modules.commandline,
44 get modes() this.modules.modes,
45 get options() this.modules.options,
47 openPrompt: function (mode) {
49 this.CommandMode(mode).open();
51 if (this.rangeFind && equals(this.rangeFind.window.get(), this.window))
52 this.rangeFind.reset();
53 this.find("", mode == this.modes.FIND_BACKWARD);
56 bootstrap: function (str, backward) {
57 if (arguments.length < 2 && this.rangeFind)
58 backward = this.rangeFind.reverse;
60 let highlighted = this.rangeFind && this.rangeFind.highlighted;
61 let selections = this.rangeFind && this.rangeFind.selections;
62 let linksOnly = false;
64 let matchCase = this.options["findcase"] === "smart" ? /[A-Z]/.test(str) :
65 this.options["findcase"] === "ignore" ? false : true;
67 str = str.replace(/\\(.|$)/g, function (m, n1) {
85 // It's possible, with :tabdetach for instance, for the rangeFind to
86 // actually move from one window to another, which breaks things.
88 || !equals(this.rangeFind.window.get(), this.window)
89 || linksOnly != !!this.rangeFind.elementPath
90 || regexp != this.rangeFind.regexp
91 || matchCase != this.rangeFind.matchCase
92 || !!backward != this.rangeFind.reverse) {
95 this.rangeFind.cancel();
96 this.rangeFind = RangeFind(this.window, matchCase, backward,
97 linksOnly && this.options.get("hinttags").matcher,
99 this.rangeFind.highlighted = highlighted;
100 this.rangeFind.selections = selections;
102 return this.lastFindPattern = str;
105 find: function (pattern, backwards) {
106 let str = this.bootstrap(pattern, backwards);
107 if (!this.rangeFind.find(str))
108 this.dactyl.echoerr(_("finder.notFound", pattern),
109 this.commandline.FORCE_SINGLELINE);
111 return this.rangeFind.found;
114 findAgain: function (reverse) {
116 this.find(this.lastFindPattern);
117 else if (!this.rangeFind.find(null, reverse))
118 this.dactyl.echoerr(_("finder.notFound", this.lastFindPattern),
119 this.commandline.FORCE_SINGLELINE);
120 else if (this.rangeFind.wrapped) {
121 let msg = this.rangeFind.backward ? _("finder.atTop")
122 : _("finder.atBottom");
123 this.commandline.echo(msg, "WarningMsg", this.commandline.APPEND_TO_MESSAGES
124 | this.commandline.FORCE_SINGLELINE);
127 this.commandline.echo((this.rangeFind.backward ? "?" : "/") + this.lastFindPattern,
128 "Normal", this.commandline.FORCE_SINGLELINE);
130 if (this.options["hlfind"])
132 this.rangeFind.focus();
135 onCancel: function () {
137 this.rangeFind.cancel();
140 onChange: function (command) {
141 if (this.options["incfind"]) {
142 command = this.bootstrap(command);
143 this.rangeFind.find(command);
147 onHistory: function () {
148 this.rangeFind.found = false;
151 onSubmit: function (command) {
152 if (!this.options["incfind"] || !this.rangeFind || !this.rangeFind.found) {
154 this.find(command || this.lastFindPattern, this.modes.extended & this.modes.FIND_BACKWARD);
157 if (this.options["hlfind"])
159 this.rangeFind.focus();
163 * Highlights all occurrences of the last sought for string in the
166 highlight: function () {
168 this.rangeFind.highlight();
172 * Clears all find highlighting.
176 this.rangeFind.highlight(true);
180 modes: function initModes(dactyl, modules, window) {
181 initModes.require("commandline");
183 const { modes } = modules;
185 modes.addMode("FIND", {
186 description: "Find mode, active when typing search input",
187 bases: [modes.COMMAND_LINE]
189 modes.addMode("FIND_FORWARD", {
190 description: "Forward Find mode, active when typing search input",
193 modes.addMode("FIND_BACKWARD", {
194 description: "Backward Find mode, active when typing search input",
198 commands: function initCommands(dactyl, modules, window) {
199 const { commands, rangefinder } = modules;
200 commands.add(["noh[lfind]"],
201 "Remove the find highlighting",
202 function () { rangefinder.clear(); },
205 commandline: function initCommandline(dactyl, modules, window) {
206 const { rangefinder } = modules;
207 rangefinder.CommandMode = Class("CommandFindMode", modules.CommandMode, {
208 init: function init(mode) {
210 init.supercall(this);
215 get prompt() this.mode === modules.modes.FIND_BACKWARD ? "?" : "/",
217 get onCancel() modules.rangefinder.closure.onCancel,
218 get onChange() modules.rangefinder.closure.onChange,
219 get onHistory() modules.rangefinder.closure.onHistory,
220 get onSubmit() modules.rangefinder.closure.onSubmit
223 mappings: function (dactyl, modules, window) {
224 const { Buffer, buffer, config, mappings, modes, rangefinder } = modules;
225 var myModes = config.browserModes.concat([modes.CARET]);
227 mappings.add(myModes,
228 ["/", "<find-forward>"], "Find a pattern starting at the current caret position",
229 function () { rangefinder.openPrompt(modes.FIND_FORWARD); });
231 mappings.add(myModes,
232 ["?", "<find-backward>"], "Find a pattern backward of the current caret position",
233 function () { rangefinder.openPrompt(modes.FIND_BACKWARD); });
235 mappings.add(myModes,
236 ["n", "<find-next>"], "Find next",
237 function () { rangefinder.findAgain(false); });
239 mappings.add(myModes,
240 ["N", "<find-previous>"], "Find previous",
241 function () { rangefinder.findAgain(true); });
243 mappings.add(myModes.concat([modes.CARET, modes.TEXT_EDIT]), ["*", "<find-word-forward>"],
244 "Find word under cursor",
246 rangefinder.find(Buffer.currentWord(buffer.focusedFrame, true), false);
247 rangefinder.findAgain();
250 mappings.add(myModes.concat([modes.CARET, modes.TEXT_EDIT]), ["#", "<find-word-backward>"],
251 "Find word under cursor backwards",
253 rangefinder.find(Buffer.currentWord(buffer.focusedFrame, true), true);
254 rangefinder.findAgain();
258 options: function (dactyl, modules, window) {
259 const { options, rangefinder } = modules;
260 const { prefs } = require("prefs");
262 options.add(["hlfind", "hlf"],
263 "Highlight all /find pattern matches on the current page after submission",
265 setter: function (value) {
266 rangefinder[value ? "highlight" : "clear"]();
271 options.add(["findcase", "fc"],
272 "Find case matching mode",
276 "smart": "Case is significant when capital letters are typed",
277 "match": "Case is always significant",
278 "ignore": "Case is never significant"
282 options.add(["incfind", "if"],
283 "Find a pattern incrementally as it is typed rather than awaiting c_<Return>",
291 * A fairly sophisticated typeahead-find replacement. It supports
292 * incremental find very much as the builtin component.
293 * Additionally, it supports several features impossible to
294 * implement using the standard component. Incremental finding
295 * works both forwards and backwards. Erasing characters during an
296 * incremental find moves the selection back to the first
297 * available match for the shorter term. The selection and viewport
298 * are restored when the find is canceled.
300 * Also, in addition to full support for frames and iframes, this
301 * implementation will begin finding from the position of the
302 * caret in the last active frame. This is contrary to the behavior
303 * of the builtin component, which always starts a find from the
304 * beginning of the first frame in the case of frameset documents,
305 * and cycles through all frames from beginning to end. This makes it
306 * impossible to choose the starting point of a find for such
307 * documents, and represents a major detriment to productivity where
308 * large amounts of data are concerned (e.g., for API documents).
310 var RangeFind = Class("RangeFind", {
311 init: function init(window, matchCase, backward, elementPath, regexp) {
312 this.window = Cu.getWeakReference(window);
313 this.content = window.content;
315 this.baseDocument = Cu.getWeakReference(this.content.document);
316 this.elementPath = elementPath || null;
317 this.reverse = Boolean(backward);
319 this.finder = services.Find();
320 this.matchCase = Boolean(matchCase);
321 this.regexp = Boolean(regexp);
325 this.highlighted = null;
326 this.selections = [];
327 this.lastString = "";
330 get store() this.content.document.dactylStore = this.content.document.dactylStore || {},
332 get backward() this.finder.findBackwards,
334 get matchCase() this.finder.caseSensitive,
335 set matchCase(val) this.finder.caseSensitive = Boolean(val),
337 get regexp() this.finder.regularExpression || false,
340 return this.finder.regularExpression = Boolean(val);
347 get findString() this.lastString,
349 get selectedRange() {
350 let win = this.store.focusedFrame && this.store.focusedFrame.get() || this.content;
352 let selection = win.getSelection();
353 return (selection.rangeCount ? selection.getRangeAt(0) : this.ranges[0].range).cloneRange();
355 set selectedRange(range) {
356 this.range.selection.removeAllRanges();
357 this.range.selection.addRange(range);
358 this.range.selectionController.scrollSelectionIntoView(
359 this.range.selectionController.SELECTION_NORMAL, 0, false);
361 this.store.focusedFrame = Cu.getWeakReference(range.startContainer.ownerDocument.defaultView);
364 cancel: function cancel() {
365 this.purgeListeners();
366 this.range.deselect();
367 this.range.descroll();
370 compareRanges: function compareRanges(r1, r2) {
372 return this.backward ? r1.compareBoundaryPoints(r1.END_TO_START, r2)
373 : -r1.compareBoundaryPoints(r1.START_TO_END, r2);
381 findRange: function findRange(range) {
382 let doc = range.startContainer.ownerDocument;
383 let win = doc.defaultView;
384 let ranges = this.ranges.filter(function (r)
385 r.window === win && RangeFind.sameDocument(r.range, range) && RangeFind.contains(r.range, range));
388 return ranges[ranges.length - 1];
392 findSubRanges: function findSubRanges(range) {
393 let doc = range.startContainer.ownerDocument;
394 for (let elem in this.elementPath(doc)) {
395 let r = RangeFind.nodeRange(elem);
396 if (RangeFind.contains(range, r))
401 focus: function focus() {
403 var node = util.evaluateXPath(RangeFind.selectNodePath,
404 this.lastRange.commonAncestorContainer).snapshotItem(0);
407 // Re-highlight collapsed selection
408 this.selectedRange = this.lastRange;
412 highlight: function highlight(clear) {
413 if (!clear && (!this.lastString || this.lastString == this.highlighted))
415 if (clear && !this.highlighted)
418 if (!clear && this.highlighted)
419 this.highlight(true);
422 this.selections.forEach(function (selection) {
423 selection.removeAllRanges();
425 this.selections = [];
426 this.highlighted = null;
429 this.selections = [];
430 let string = this.lastString;
431 for (let r in this.iter(string)) {
432 let controller = this.range.selectionController;
433 for (let node = r.startContainer; node; node = node.parentNode)
434 if (node instanceof Ci.nsIDOMNSEditableElement) {
435 controller = node.editor.selectionController;
439 let sel = controller.getSelection(Ci.nsISelectionController.SELECTION_FIND);
441 if (this.selections.indexOf(sel) < 0)
442 this.selections.push(sel);
444 this.highlighted = this.lastString;
446 this.selectedRange = this.lastRange;
451 indexIter: function (private_) {
452 let idx = this.range.index;
454 var groups = [util.range(idx + 1, 0, -1), util.range(this.ranges.length, idx, -1)];
456 var groups = [util.range(idx, this.ranges.length), util.range(0, idx + 1)];
458 for (let i in groups[0])
463 this.lastRange = null;
464 for (let i in groups[1])
469 iter: function (word) {
470 let saved = ["lastRange", "lastString", "range"].map(function (s) [s, this[s]], this);
472 this.range = this.ranges[0];
473 this.lastRange = null;
474 this.lastString = word;
476 while (res = this.find(null, this.reverse, true))
480 saved.forEach(function ([k, v]) this[k] = v, this);
484 makeFrameList: function (win) {
490 function pushRange(start, end) {
492 if (r = RangeFind.Range(r, frames.length))
496 let doc = start.startContainer.ownerDocument;
498 let range = doc.createRange();
499 range.setStart(start.startContainer, start.startOffset);
500 range.setEnd(end.startContainer, end.startOffset);
502 if (!self.elementPath)
505 for (let r in self.findSubRanges(range))
509 let doc = win.document;
510 let pageRange = RangeFind[doc.body ? "nodeRange" : "nodeContents"](doc.body || doc.documentElement);
511 backup = backup || pageRange;
512 let pageStart = RangeFind.endpoint(pageRange, true);
513 let pageEnd = RangeFind.endpoint(pageRange, false);
515 for (let frame in array.iterValues(win.frames)) {
516 let range = doc.createRange();
517 if (util.computedStyle(frame.frameElement).visibility == "visible") {
518 range.selectNode(frame.frameElement);
519 pushRange(pageStart, RangeFind.endpoint(range, true));
520 pageStart = RangeFind.endpoint(range, false);
524 pushRange(pageStart, pageEnd);
526 let anonNodes = doc.getAnonymousNodes(doc.documentElement);
528 for (let [, elem] in iter(anonNodes)) {
529 let range = RangeFind.nodeContents(elem);
530 pushRange(RangeFind.endpoint(range, true), RangeFind.endpoint(range, false));
535 if (frames.length == 0)
536 frames[0] = RangeFind.Range(RangeFind.endpoint(backup, true), 0);
541 this.ranges = this.makeFrameList(this.content);
543 this.startRange = this.selectedRange;
544 this.startRange.collapse(!this.reverse);
545 this.lastRange = this.selectedRange;
546 this.range = this.findRange(this.startRange);
547 this.ranges.first = this.range;
548 this.ranges.forEach(function (range) range.save());
553 // This doesn't work yet.
554 resetCaret: function () {
555 let equal = RangeFind.equal;
556 let selection = this.win.getSelection();
557 if (selection.rangeCount == 0)
558 selection.addRange(this.pageStart);
559 function getLines() {
560 let orig = selection.getRangeAt(0);
561 function getRanges(forward) {
562 selection.removeAllRanges();
563 selection.addRange(orig);
567 this.sel.lineMove(forward, false);
568 cur = selection.getRangeAt(0);
569 if (equal(cur, last))
575 for (let range in getRanges(true))
577 for (let range in getRanges(false))
580 for (let range in getLines()) {
581 if (this.sel.checkVisibility(range.startContainer, range.startOffset, range.startOffset))
587 find: function (word, reverse, private_) {
588 if (!private_ && this.lastRange && !RangeFind.equal(this.selectedRange, this.lastRange))
591 this.wrapped = false;
592 this.finder.findBackwards = reverse ? !this.reverse : this.reverse;
593 let again = word == null;
595 word = this.lastString;
597 word = word.toLowerCase();
599 if (!again && (word === "" || word.indexOf(this.lastString) !== 0 || this.backward)) {
601 this.range.deselect();
603 this.range.descroll();
604 this.lastRange = this.startRange;
605 this.range = this.ranges.first;
609 var range = this.startRange;
611 for (let i in this.indexIter(private_)) {
612 if (!private_ && this.range.window != this.ranges[i].window && this.range.window != this.ranges[i].window.parent) {
613 this.range.descroll();
614 this.range.deselect();
616 this.range = this.ranges[i];
618 let start = RangeFind.sameDocument(this.lastRange, this.range.range) && this.range.intersects(this.lastRange) ?
619 RangeFind.endpoint(this.lastRange, !(again ^ this.backward)) :
620 RangeFind.endpoint(this.range.range, !this.backward);
622 if (this.backward && !again)
623 start = RangeFind.endpoint(this.startRange, false);
625 var range = this.finder.Find(word, this.range.range, start, this.range.range);
631 this.lastRange = range.cloneRange();
633 this.lastString = word;
641 if (range && (!private_ || private_ < 0))
642 this.selectedRange = range;
646 get stale() this._stale || this.baseDocument.get() != this.content.document,
647 set stale(val) this._stale = val,
649 addListeners: function () {
650 for (let range in array.iterValues(this.ranges))
651 range.window.addEventListener("unload", this.closure.onUnload, true);
653 purgeListeners: function () {
654 for (let range in array.iterValues(this.ranges))
656 range.window.removeEventListener("unload", this.closure.onUnload, true);
658 catch (e if e.result === Cr.NS_ERROR_FAILURE) {}
660 onUnload: function (event) {
661 this.purgeListeners();
662 if (this.highlighted)
663 this.highlight(true);
667 Range: Class("RangeFind.Range", {
668 init: function (range, index) {
672 this.document = range.startContainer.ownerDocument;
673 this.window = this.document.defaultView;
674 this.docShell = this.window.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIWebNavigation)
675 .QueryInterface(Ci.nsIDocShell);
677 if (this.selection == null)
683 intersects: function (range) RangeFind.intersects(this.range, range),
686 this.scroll = Point(this.window.pageXOffset, this.window.pageYOffset);
688 this.initialSelection = null;
689 if (this.selection.rangeCount)
690 this.initialSelection = this.selection.getRangeAt(0);
693 descroll: function () {
694 this.window.scrollTo(this.scroll.x, this.scroll.y);
697 deselect: function () {
698 if (this.selection) {
699 this.selection.removeAllRanges();
700 if (this.initialSelection)
701 this.selection.addRange(this.initialSelection);
705 get selectionController() this.docShell
706 .QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsISelectionDisplay)
707 .QueryInterface(Ci.nsISelectionController),
710 return this.selectionController.getSelection(Ci.nsISelectionController.SELECTION_NORMAL);
717 contains: function (range, r) {
719 return range.compareBoundaryPoints(range.START_TO_END, r) >= 0 &&
720 range.compareBoundaryPoints(range.END_TO_START, r) <= 0;
723 util.reportError(e, true);
727 intersects: function (range, r) {
729 return r.compareBoundaryPoints(range.START_TO_END, range) >= 0 &&
730 r.compareBoundaryPoints(range.END_TO_START, range) <= 0;
733 util.reportError(e, true);
737 endpoint: function (range, before) {
738 range = range.cloneRange();
739 range.collapse(before);
742 equal: function (r1, r2) {
744 return !r1.compareBoundaryPoints(r1.START_TO_START, r2) && !r1.compareBoundaryPoints(r1.END_TO_END, r2);
750 nodeContents: function (node) {
751 let range = node.ownerDocument.createRange();
753 range.selectNodeContents(node);
758 nodeRange: function (node) {
759 let range = node.ownerDocument.createRange();
761 range.selectNode(node);
766 sameDocument: function (r1, r2) {
767 if (!(r1 && r2 && r1.endContainer.ownerDocument == r2.endContainer.ownerDocument))
770 r1.compareBoundaryPoints(r1.START_TO_START, r2);
772 catch (e if e.result == 0x80530004 /* NS_ERROR_DOM_WRONG_DOCUMENT_ERR */) {
777 selectNodePath: ["a", "xhtml:a", "*[@onclick]"].map(function (p) "ancestor-or-self::" + p).join(" | ")
780 } catch(e){ if (typeof e === "string") e = Error(e); dump(e.fileName+":"+e.lineNumber+": "+e+"\n" + e.stack); }
785 // vim: set fdm=marker sw=4 ts=4 et ft=javascript: