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"],
13 this.lazyRequire("buffer", ["Buffer"]);
14 this.lazyRequire("overlay", ["overlay"]);
16 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 { window } = this.modes.getStack(0).params;
30 return window || this.window.content;
34 let find = overlay.getData(this.content.document,
37 if (!isinstance(find, RangeFind) || find.stale)
38 return this.rangeFind = null;
41 set rangeFind(val) overlay.setData(this.content.document,
45 init: function init() {
46 prefs.safeSet("accessibility.typeaheadfind.autostart", false);
47 // The above should be sufficient, but: http://dactyl.sf.net/bmo/348187
48 prefs.safeSet("accessibility.typeaheadfind", false);
51 cleanup: function cleanup() {
52 for (let doc in util.iterDocuments()) {
53 let find = overlay.getData(doc, "range-find", null);
57 overlay.setData(doc, "range-find", null);
61 get commandline() this.modules.commandline,
62 get modes() this.modules.modes,
63 get options() this.modules.options,
65 openPrompt: function openPrompt(mode) {
66 this.modules.marks.push();
68 this.CommandMode(mode, this.content).open();
70 Buffer(this.content).resetCaret();
72 if (this.rangeFind && equals(this.rangeFind.window.get(), this.window))
73 this.rangeFind.reset();
74 this.find("", mode == this.modes.FIND_BACKWARD);
77 bootstrap: function bootstrap(str, backward) {
78 if (arguments.length < 2 && this.rangeFind)
79 backward = this.rangeFind.reverse;
81 let highlighted = this.rangeFind && this.rangeFind.highlighted;
82 let selections = this.rangeFind && this.rangeFind.selections;
83 let linksOnly = false;
85 let matchCase = this.options["findcase"] === "smart" ? /[A-Z]/.test(str) :
86 this.options["findcase"] === "ignore" ? false : true;
88 function replacer(m, n1) {
106 this.options["findflags"].forEach(function (f) replacer(f, f));
108 let pattern = str.replace(/\\(.|$)/g, replacer);
111 this.lastFindPattern = str;
112 // It's possible, with :tabdetach for instance, for the rangeFind to
113 // actually move from one window to another, which breaks things.
115 || !equals(this.rangeFind.window.get(), this.window)
116 || linksOnly != !!this.rangeFind.elementPath
117 || regexp != this.rangeFind.regexp
118 || matchCase != this.rangeFind.matchCase
119 || !!backward != this.rangeFind.reverse) {
122 this.rangeFind.cancel();
123 this.rangeFind = null;
124 this.rangeFind = RangeFind(this.window, this.content, matchCase, backward,
125 linksOnly && this.options.get("hinttags").matcher,
127 this.rangeFind.highlighted = highlighted;
128 this.rangeFind.selections = selections;
130 this.rangeFind.pattern = str;
134 find: function find(pattern, backwards) {
135 this.modules.marks.push();
136 let str = this.bootstrap(pattern, backwards);
137 this.backward = this.rangeFind.reverse;
139 if (!this.rangeFind.find(str))
140 this.dactyl.echoerr(_("finder.notFound", pattern),
141 this.commandline.FORCE_SINGLELINE);
143 return this.rangeFind.found;
146 findAgain: function findAgain(reverse) {
147 this.modules.marks.push();
149 this.find(this.lastFindPattern);
150 else if (!this.rangeFind.find(null, reverse))
151 this.dactyl.echoerr(_("finder.notFound", this.lastFindPattern),
152 this.commandline.FORCE_SINGLELINE);
153 else if (this.rangeFind.wrapped) {
154 let msg = this.rangeFind.backward ? _("finder.atTop")
155 : _("finder.atBottom");
156 this.commandline.echo(msg, "WarningMsg", this.commandline.APPEND_TO_MESSAGES
157 | this.commandline.FORCE_SINGLELINE);
160 this.commandline.echo((this.rangeFind.backward ? "?" : "/") + this.rangeFind.pattern,
161 "Normal", this.commandline.FORCE_SINGLELINE);
163 if (this.options["hlfind"])
165 this.rangeFind.focus();
168 onCancel: function onCancel() {
170 this.rangeFind.cancel();
173 onChange: function onChange(command) {
174 if (this.options["incfind"]) {
175 command = this.bootstrap(command);
176 this.rangeFind.find(command);
180 onHistory: function onHistory() {
181 this.rangeFind.found = false;
184 onSubmit: function onSubmit(command) {
185 if (!command && this.lastFindPattern) {
186 this.find(this.lastFindPattern, this.backward);
191 if (!this.options["incfind"] || !this.rangeFind || !this.rangeFind.found) {
193 this.find(command || this.lastFindPattern, this.backward);
196 if (this.options["hlfind"])
198 this.rangeFind.focus();
202 * Highlights all occurrences of the last sought for string in the
205 highlight: function highlight() {
207 this.rangeFind.highlight();
211 * Clears all find highlighting.
213 clear: function clear() {
215 this.rangeFind.highlight(true);
219 modes: function initModes(dactyl, modules, window) {
220 initModes.require("commandline");
222 const { modes } = modules;
224 modes.addMode("FIND", {
225 description: "Find mode, active when typing search input",
226 bases: [modes.COMMAND_LINE]
228 modes.addMode("FIND_FORWARD", {
229 description: "Forward Find mode, active when typing search input",
232 modes.addMode("FIND_BACKWARD", {
233 description: "Backward Find mode, active when typing search input",
237 commands: function initCommands(dactyl, modules, window) {
238 const { commands, rangefinder } = modules;
239 commands.add(["noh[lfind]"],
240 "Remove the find highlighting",
241 function () { rangefinder.clear(); },
244 commandline: function initCommandline(dactyl, modules, window) {
245 const { rangefinder } = modules;
246 rangefinder.CommandMode = Class("CommandFindMode", modules.CommandMode, {
247 init: function init(mode, window) {
249 this.window = window;
250 init.supercall(this);
255 get prompt() this.mode === modules.modes.FIND_BACKWARD ? "?" : "/",
257 get onCancel() modules.rangefinder.closure.onCancel,
258 get onChange() modules.rangefinder.closure.onChange,
259 get onHistory() modules.rangefinder.closure.onHistory,
260 get onSubmit() modules.rangefinder.closure.onSubmit
263 mappings: function (dactyl, modules, window) {
264 const { Buffer, buffer, config, mappings, modes, rangefinder } = modules;
265 var myModes = config.browserModes.concat([modes.CARET]);
267 mappings.add(myModes,
268 ["/", "<find-forward>"], "Find a pattern starting at the current caret position",
269 function () { rangefinder.openPrompt(modes.FIND_FORWARD); });
271 mappings.add(myModes,
272 ["?", "<find-backward>", "<S-Slash>"], "Find a pattern backward of the current caret position",
273 function () { rangefinder.openPrompt(modes.FIND_BACKWARD); });
275 mappings.add(myModes,
276 ["n", "<find-next>"], "Find next",
277 function () { rangefinder.findAgain(false); });
279 mappings.add(myModes,
280 ["N", "<find-previous>"], "Find previous",
281 function () { rangefinder.findAgain(true); });
283 mappings.add(myModes.concat([modes.CARET, modes.TEXT_EDIT]), ["*", "<find-word-forward>"],
284 "Find word under cursor",
286 rangefinder.find(Buffer.currentWord(buffer.focusedFrame, true), false);
287 rangefinder.findAgain();
290 mappings.add(myModes.concat([modes.CARET, modes.TEXT_EDIT]), ["#", "<find-word-backward>"],
291 "Find word under cursor backwards",
293 rangefinder.find(Buffer.currentWord(buffer.focusedFrame, true), true);
294 rangefinder.findAgain();
298 options: function (dactyl, modules, window) {
299 const { options, rangefinder } = modules;
301 options.add(["hlfind", "hlf"],
302 "Highlight all /find pattern matches on the current page after submission",
304 setter: function (value) {
305 rangefinder[value ? "highlight" : "clear"]();
310 options.add(["findcase", "fc"],
311 "Find case matching mode",
315 "smart": "Case is significant when capital letters are typed",
316 "match": "Case is always significant",
317 "ignore": "Case is never significant"
321 options.add(["findflags", "ff"],
322 "Default flags for find invocations",
328 "r": "Perform a regular expression search",
329 "R": "Perform a plain string search",
330 "l": "Search only in links",
331 "L": "Search all text"
335 options.add(["incfind", "if"],
336 "Find a pattern incrementally as it is typed rather than awaiting c_<Return>",
344 * A fairly sophisticated typeahead-find replacement. It supports
345 * incremental find very much as the builtin component.
346 * Additionally, it supports several features impossible to
347 * implement using the standard component. Incremental finding
348 * works both forwards and backwards. Erasing characters during an
349 * incremental find moves the selection back to the first
350 * available match for the shorter term. The selection and viewport
351 * are restored when the find is canceled.
353 * Also, in addition to full support for frames and iframes, this
354 * implementation will begin finding from the position of the
355 * caret in the last active frame. This is contrary to the behavior
356 * of the builtin component, which always starts a find from the
357 * beginning of the first frame in the case of frameset documents,
358 * and cycles through all frames from beginning to end. This makes it
359 * impossible to choose the starting point of a find for such
360 * documents, and represents a major detriment to productivity where
361 * large amounts of data are concerned (e.g., for API documents).
363 var RangeFind = Class("RangeFind", {
364 init: function init(window, content, matchCase, backward, elementPath, regexp) {
365 this.window = util.weakReference(window);
366 this.content = content;
368 this.baseDocument = util.weakReference(this.content.document);
369 this.elementPath = elementPath || null;
370 this.reverse = Boolean(backward);
372 this.finder = services.Find();
373 this.matchCase = Boolean(matchCase);
374 this.regexp = Boolean(regexp);
378 this.highlighted = null;
379 this.selections = [];
380 this.lastString = "";
383 get store() overlay.getData(this.content.document, "buffer", Object),
385 get backward() this.finder.findBackwards,
386 set backward(val) this.finder.findBackwards = val,
388 get matchCase() this.finder.caseSensitive,
389 set matchCase(val) this.finder.caseSensitive = Boolean(val),
391 get findString() this.lastString,
393 get flags() this.matchCase ? "" : "i",
395 get selectedRange() {
396 let win = this.store.focusedFrame && this.store.focusedFrame.get() || this.content;
398 let selection = win.getSelection();
399 return (selection.rangeCount ? selection.getRangeAt(0) : this.ranges[0].range).cloneRange();
401 set selectedRange(range) {
402 this.range.selection.removeAllRanges();
403 this.range.selection.addRange(range);
404 this.range.selectionController.scrollSelectionIntoView(
405 this.range.selectionController.SELECTION_NORMAL, 0, false);
407 this.store.focusedFrame = util.weakReference(range.startContainer.ownerDocument.defaultView);
410 cancel: function cancel() {
411 this.purgeListeners();
413 this.range.deselect();
414 this.range.descroll();
418 compareRanges: function compareRanges(r1, r2) {
420 return this.backward ? r1.compareBoundaryPoints(r1.END_TO_START, r2)
421 : -r1.compareBoundaryPoints(r1.START_TO_END, r2);
429 findRange: function findRange(range) {
430 let doc = range.startContainer.ownerDocument;
431 let win = doc.defaultView;
432 let ranges = this.ranges.filter(function (r)
433 r.window === win && RangeFind.sameDocument(r.range, range) && RangeFind.contains(r.range, range));
436 return ranges[ranges.length - 1];
440 findSubRanges: function findSubRanges(range) {
441 let doc = range.startContainer.ownerDocument;
442 for (let elem in this.elementPath(doc)) {
443 let r = RangeFind.nodeRange(elem);
444 if (RangeFind.contains(range, r))
449 focus: function focus() {
451 var node = DOM.XPath(RangeFind.selectNodePath,
452 this.lastRange.commonAncestorContainer).snapshotItem(0);
455 // Re-highlight collapsed selection
456 this.selectedRange = this.lastRange;
460 highlight: function highlight(clear) {
461 if (!clear && (!this.lastString || this.lastString == this.highlighted))
463 if (clear && !this.highlighted)
466 if (!clear && this.highlighted)
467 this.highlight(true);
470 this.selections.forEach(function (selection) {
471 selection.removeAllRanges();
473 this.selections = [];
474 this.highlighted = null;
477 this.selections = [];
478 let string = this.lastString;
479 for (let r in this.iter(string)) {
480 let controller = this.range.selectionController;
481 for (let node = r.startContainer; node; node = node.parentNode)
482 if (node instanceof Ci.nsIDOMNSEditableElement) {
483 controller = node.editor.selectionController;
487 let sel = controller.getSelection(Ci.nsISelectionController.SELECTION_FIND);
489 if (this.selections.indexOf(sel) < 0)
490 this.selections.push(sel);
492 this.highlighted = this.lastString;
494 this.selectedRange = this.lastRange;
499 indexIter: function indexIter(private_) {
500 let idx = this.range.index;
502 var groups = [util.range(idx + 1, 0, -1), util.range(this.ranges.length, idx, -1)];
504 var groups = [util.range(idx, this.ranges.length), util.range(0, idx + 1)];
506 for (let i in groups[0])
511 this.lastRange = null;
512 for (let i in groups[1])
517 iter: function iter(word) {
518 let saved = ["lastRange", "lastString", "range", "regexp"].map(function (s) [s, this[s]], this);
521 let regexp = this.regexp && word != util.regexp.escape(word);
522 this.lastRange = null;
525 let re = RegExp(word, "gm" + this.flags);
526 for (this.range in array.iterValues(this.ranges)) {
527 for (let match in util.regexp.iterate(re, DOM.stringify(this.range.range, true))) {
528 let lastRange = this.lastRange;
529 if (res = this.find(null, this.reverse, true))
532 this.lastRange = lastRange;
537 this.range = this.ranges[0];
538 this.lastString = word;
539 while (res = this.find(null, this.reverse, true))
544 saved.forEach(function ([k, v]) this[k] = v, this);
548 makeFrameList: function makeFrameList(win) {
554 function pushRange(start, end) {
556 if (r = RangeFind.Range(r, frames.length))
560 let doc = start.startContainer.ownerDocument;
562 let range = doc.createRange();
563 range.setStart(start.startContainer, start.startOffset);
564 range.setEnd(end.startContainer, end.startOffset);
566 if (!self.elementPath)
569 for (let r in self.findSubRanges(range))
573 let doc = win.document;
574 let pageRange = RangeFind[doc.body ? "nodeRange" : "nodeContents"](doc.body || doc.documentElement);
575 backup = backup || pageRange;
576 let pageStart = RangeFind.endpoint(pageRange, true);
577 let pageEnd = RangeFind.endpoint(pageRange, false);
579 for (let frame in array.iterValues(win.frames)) {
580 let range = doc.createRange();
581 if (DOM(frame.frameElement).style.visibility == "visible") {
582 range.selectNode(frame.frameElement);
583 pushRange(pageStart, RangeFind.endpoint(range, true));
584 pageStart = RangeFind.endpoint(range, false);
588 pushRange(pageStart, pageEnd);
590 let anonNodes = doc.getAnonymousNodes(doc.documentElement);
592 for (let [, elem] in iter(anonNodes)) {
593 let range = RangeFind.nodeContents(elem);
594 pushRange(RangeFind.endpoint(range, true), RangeFind.endpoint(range, false));
599 if (frames.length == 0)
600 frames[0] = RangeFind.Range(RangeFind.endpoint(backup, true), 0);
604 reset: function reset() {
605 this.ranges = this.makeFrameList(this.content);
607 this.startRange = this.selectedRange;
608 this.startRange.collapse(!this.reverse);
609 this.lastRange = this.selectedRange;
610 this.range = this.findRange(this.startRange) || this.ranges[0];
611 util.assert(this.range, "Null range", false);
612 this.ranges.first = this.range;
613 this.ranges.forEach(function (range) range.save());
618 find: function find(pattern, reverse, private_) {
619 if (!private_ && this.lastRange && !RangeFind.equal(this.selectedRange, this.lastRange))
622 this.wrapped = false;
623 this.backward = reverse ? !this.reverse : this.reverse;
624 let again = pattern == null;
626 pattern = this.lastString;
628 pattern = pattern.toLowerCase();
630 if (!again && (pattern === "" || pattern.indexOf(this.lastString) !== 0 || this.backward)) {
632 this.range.deselect();
634 this.range.descroll();
635 this.lastRange = this.startRange;
636 this.range = this.ranges.first;
640 let regexp = this.regexp && word != util.regexp.escape(word);
651 var range = this.startRange;
653 for (let i in this.indexIter(private_)) {
654 if (!private_ && this.range.window != this.ranges[i].window && this.range.window != this.ranges[i].window.parent) {
655 this.range.descroll();
656 this.range.deselect();
658 this.range = this.ranges[i];
660 let start = RangeFind.sameDocument(this.lastRange, this.range.range) && this.range.intersects(this.lastRange) ?
661 RangeFind.endpoint(this.lastRange, !(again ^ this.backward)) :
662 RangeFind.endpoint(this.range.range, !this.backward);
664 if (this.backward && !again)
665 start = RangeFind.endpoint(this.startRange, false);
668 let range = this.range.range.cloneRange();
669 range[this.backward ? "setEnd" : "setStart"](
670 start.startContainer, start.startOffset);
671 range = DOM.stringify(range);
674 var match = RegExp(pattern, "m" + this.flags).exec(range);
676 match = RegExp("[^]*(?:" + pattern + ")", "m" + this.flags).exec(range);
678 match = RegExp(pattern + "$", this.flags).exec(match[0]);
680 if (!(match && match[0]))
685 var range = this.finder.Find(word, this.range.range, start, this.range.range);
686 if (range && DOM(range.commonAncestorContainer).isVisible)
691 this.lastRange = range.cloneRange();
693 this.lastString = pattern;
701 if (range && (!private_ || private_ < 0))
702 this.selectedRange = range;
706 get stale() this._stale || this.baseDocument.get() != this.content.document,
707 set stale(val) this._stale = val,
709 addListeners: function addListeners() {
710 for (let range in array.iterValues(this.ranges))
711 range.window.addEventListener("unload", this.closure.onUnload, true);
713 purgeListeners: function purgeListeners() {
714 for (let range in array.iterValues(this.ranges))
716 range.window.removeEventListener("unload", this.closure.onUnload, true);
718 catch (e if e.result === Cr.NS_ERROR_FAILURE) {}
720 onUnload: function onUnload(event) {
721 this.purgeListeners();
722 if (this.highlighted)
723 this.highlight(true);
727 Range: Class("RangeFind.Range", {
728 init: function init(range, index) {
732 this.document = range.startContainer.ownerDocument;
733 this.window = this.document.defaultView;
735 if (this.selection == null)
741 docShell: Class.Memoize(function () util.docShell(this.window)),
743 intersects: function (range) RangeFind.intersects(this.range, range),
745 save: function save() {
746 this.scroll = Point(this.window.pageXOffset, this.window.pageYOffset);
748 this.initialSelection = null;
749 if (this.selection.rangeCount)
750 this.initialSelection = this.selection.getRangeAt(0);
753 descroll: function descroll() {
754 this.window.scrollTo(this.scroll.x, this.scroll.y);
757 deselect: function deselect() {
758 if (this.selection) {
759 this.selection.removeAllRanges();
760 if (this.initialSelection)
761 this.selection.addRange(this.initialSelection);
765 get selectionController() this.docShell
766 .QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsISelectionDisplay)
767 .QueryInterface(Ci.nsISelectionController),
770 return this.selectionController.getSelection(Ci.nsISelectionController.SELECTION_NORMAL);
777 contains: function contains(range, r, quiet) {
779 return range.compareBoundaryPoints(range.START_TO_END, r) >= 0 &&
780 range.compareBoundaryPoints(range.END_TO_START, r) <= 0;
783 if (e.result != Cr.NS_ERROR_DOM_WRONG_DOCUMENT_ERR && !quiet)
784 util.reportError(e, true);
788 containsNode: function containsNode(range, n, quiet) n.ownerDocument && this.contains(range, RangeFind.nodeRange(n), quiet),
789 intersects: function intersects(range, r) {
791 return r.compareBoundaryPoints(range.START_TO_END, range) >= 0 &&
792 r.compareBoundaryPoints(range.END_TO_START, range) <= 0;
795 util.reportError(e, true);
799 endpoint: function endpoint(range, before) {
800 range = range.cloneRange();
801 range.collapse(before);
804 equal: function equal(r1, r2) {
806 return !r1.compareBoundaryPoints(r1.START_TO_START, r2) && !r1.compareBoundaryPoints(r1.END_TO_END, r2);
812 nodeContents: function nodeContents(node) {
813 let range = node.ownerDocument.createRange();
815 range.selectNodeContents(node);
820 nodeRange: function nodeRange(node) {
821 let range = node.ownerDocument.createRange();
823 range.selectNode(node);
828 sameDocument: function sameDocument(r1, r2) {
829 if (!(r1 && r2 && r1.endContainer.ownerDocument == r2.endContainer.ownerDocument))
832 r1.compareBoundaryPoints(r1.START_TO_START, r2);
834 catch (e if e.result == 0x80530004 /* NS_ERROR_DOM_WRONG_DOCUMENT_ERR */) {
839 selectNodePath: ["a", "xhtml:a", "*[@onclick]"].map(function (p) "ancestor-or-self::" + p).join(" | "),
840 union: function union(a, b) {
841 let start = a.compareBoundaryPoints(a.START_TO_START, b) < 0 ? a : b;
842 let end = a.compareBoundaryPoints(a.END_TO_END, b) > 0 ? a : b;
843 let res = start.cloneRange();
844 res.setEnd(end.endContainer, end.endOffset);
849 // catch(e){ if (typeof e === "string") e = Error(e); dump(e.fileName+":"+e.lineNumber+": "+e+"\n" + e.stack); }
853 // vim: set fdm=marker sw=4 ts=4 et ft=javascript: