1 // Copyright (c) 2008-2013 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 defineModule("finder", {
8 exports: ["RangeFind", "RangeFinder", "rangefinder"],
9 require: ["prefs", "util"]
12 lazyRequire("buffer", ["Buffer"]);
13 lazyRequire("overlay", ["overlay"]);
15 function id(w) w.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils)
17 function equals(a, b) id(a) == id(b);
19 /** @instance rangefinder */
20 var RangeFinder = Module("rangefinder", {
21 Local: function (dactyl, modules, window) ({
24 this.modules = modules;
26 this.lastFindPattern = "";
30 let { window } = this.modes.getStack(0).params;
31 return window || this.window.content;
35 let find = overlay.getData(this.content.document,
38 if (!isinstance(find, RangeFind) || find.stale)
39 return this.rangeFind = null;
42 set rangeFind(val) overlay.setData(this.content.document,
46 init: function init() {
47 prefs.safeSet("accessibility.typeaheadfind.autostart", false);
48 // The above should be sufficient, but: http://bugzil.la/348187
49 prefs.safeSet("accessibility.typeaheadfind", false);
52 cleanup: function cleanup() {
53 for (let doc in util.iterDocuments()) {
54 let find = overlay.getData(doc, "range-find", null);
58 overlay.setData(doc, "range-find", null);
62 get commandline() this.modules.commandline,
63 get modes() this.modules.modes,
64 get options() this.modules.options,
66 openPrompt: function openPrompt(mode) {
67 this.modules.marks.push();
69 this.CommandMode(mode, this.content).open();
71 Buffer(this.content).resetCaret();
73 if (this.rangeFind && equals(this.rangeFind.window.get(), this.window))
74 this.rangeFind.reset();
75 this.find("", mode == this.modes.FIND_BACKWARD);
78 bootstrap: function bootstrap(str, backward=this.rangeFind && this.rangeFind.reverse) {
80 let highlighted = this.rangeFind && this.rangeFind.highlighted;
81 let selections = this.rangeFind && this.rangeFind.selections;
82 let linksOnly = false;
84 let matchCase = this.options["findcase"] === "smart" ? /[A-Z]/.test(str) :
85 this.options["findcase"] === "ignore" ? false : true;
87 function replacer(m, n1) {
105 this.options["findflags"].forEach(f => replacer(f, f));
107 let pattern = str.replace(/\\(.|$)/g, replacer);
110 this.lastFindPattern = str;
111 // It's possible, with :tabdetach for instance, for the rangeFind to
112 // actually move from one window to another, which breaks things.
114 || !equals(this.rangeFind.window.get(), this.window)
115 || linksOnly != !!this.rangeFind.elementPath
116 || regexp != this.rangeFind.regexp
117 || matchCase != this.rangeFind.matchCase
118 || !!backward != this.rangeFind.reverse) {
121 this.rangeFind.cancel();
122 this.rangeFind = null;
123 this.rangeFind = RangeFind(this.window, this.content, matchCase, backward,
124 linksOnly && this.options.get("hinttags").matcher,
126 this.rangeFind.highlighted = highlighted;
127 this.rangeFind.selections = selections;
129 this.rangeFind.pattern = str;
133 find: function find(pattern, backwards) {
134 this.modules.marks.push();
135 let str = this.bootstrap(pattern, backwards);
136 this.backward = this.rangeFind.reverse;
138 if (!this.rangeFind.find(str))
139 this.dactyl.echoerr(_("finder.notFound", pattern),
140 this.commandline.FORCE_SINGLELINE);
142 return this.rangeFind.found;
145 findAgain: function findAgain(reverse) {
146 this.modules.marks.push();
148 this.find(this.lastFindPattern);
149 else if (!this.rangeFind.find(null, reverse))
150 this.dactyl.echoerr(_("finder.notFound", this.lastFindPattern),
151 this.commandline.FORCE_SINGLELINE);
152 else if (this.rangeFind.wrapped) {
153 let msg = this.rangeFind.backward ? _("finder.atTop")
154 : _("finder.atBottom");
155 this.commandline.echo(msg, "WarningMsg", this.commandline.APPEND_TO_MESSAGES
156 | this.commandline.FORCE_SINGLELINE);
159 this.commandline.echo((this.rangeFind.backward ? "?" : "/") + this.rangeFind.pattern,
160 "Normal", this.commandline.FORCE_SINGLELINE);
162 if (this.options["hlfind"])
164 this.rangeFind.focus();
167 onCancel: function onCancel() {
169 this.rangeFind.cancel();
172 onChange: function onChange(command) {
173 if (this.options["incfind"]) {
174 command = this.bootstrap(command);
175 this.rangeFind.find(command);
179 onHistory: function onHistory() {
180 this.rangeFind.found = false;
183 onSubmit: function onSubmit(command) {
184 if (!command && this.lastFindPattern) {
185 this.find(this.lastFindPattern, this.backward);
190 if (!this.options["incfind"] || !this.rangeFind || !this.rangeFind.found) {
192 this.find(command || this.lastFindPattern, this.backward);
195 if (this.options["hlfind"])
197 this.rangeFind.focus();
201 * Highlights all occurrences of the last sought for string in the
204 highlight: function highlight() {
206 this.rangeFind.highlight();
210 * Clears all find highlighting.
212 clear: function clear() {
214 this.rangeFind.highlight(true);
218 modes: function initModes(dactyl, modules, window) {
219 initModes.require("commandline");
221 const { modes } = modules;
223 modes.addMode("FIND", {
224 description: "Find mode, active when typing search input",
225 bases: [modes.COMMAND_LINE]
227 modes.addMode("FIND_FORWARD", {
228 description: "Forward Find mode, active when typing search input",
231 modes.addMode("FIND_BACKWARD", {
232 description: "Backward Find mode, active when typing search input",
236 commands: function initCommands(dactyl, modules, window) {
237 const { commands, rangefinder } = modules;
238 commands.add(["noh[lfind]"],
239 "Remove the find highlighting",
240 function () { rangefinder.clear(); },
243 commandline: function initCommandline(dactyl, modules, window) {
244 const { rangefinder } = modules;
245 rangefinder.CommandMode = Class("CommandFindMode", modules.CommandMode, {
246 init: function init(mode, window) {
248 this.window = window;
249 init.supercall(this);
254 get prompt() this.mode === modules.modes.FIND_BACKWARD ? "?" : "/",
256 get onCancel() modules.rangefinder.closure.onCancel,
257 get onChange() modules.rangefinder.closure.onChange,
258 get onHistory() modules.rangefinder.closure.onHistory,
259 get onSubmit() modules.rangefinder.closure.onSubmit
262 mappings: function initMappings(dactyl, modules, window) {
263 const { Buffer, buffer, config, mappings, modes, rangefinder } = modules;
264 var myModes = config.browserModes.concat([modes.CARET]);
266 mappings.add(myModes,
267 ["/", "<find-forward>"], "Find a pattern starting at the current caret position",
268 function () { rangefinder.openPrompt(modes.FIND_FORWARD); });
270 mappings.add(myModes,
271 ["?", "<find-backward>", "<S-Slash>"], "Find a pattern backward of the current caret position",
272 function () { rangefinder.openPrompt(modes.FIND_BACKWARD); });
274 mappings.add(myModes,
275 ["n", "<find-next>"], "Find next",
276 function () { rangefinder.findAgain(false); });
278 mappings.add(myModes,
279 ["N", "<find-previous>"], "Find previous",
280 function () { rangefinder.findAgain(true); });
282 mappings.add(myModes.concat([modes.CARET, modes.TEXT_EDIT]), ["*", "<find-word-forward>"],
283 "Find word under cursor",
285 rangefinder.find(Buffer.currentWord(buffer.focusedFrame, true), false);
286 rangefinder.findAgain();
289 mappings.add(myModes.concat([modes.CARET, modes.TEXT_EDIT]), ["#", "<find-word-backward>"],
290 "Find word under cursor backwards",
292 rangefinder.find(Buffer.currentWord(buffer.focusedFrame, true), true);
293 rangefinder.findAgain();
297 options: function initOptions(dactyl, modules, window) {
298 const { options, rangefinder } = modules;
300 options.add(["hlfind", "hlf"],
301 "Highlight all /find pattern matches on the current page after submission",
303 setter: function (value) {
304 rangefinder[value ? "highlight" : "clear"]();
309 options.add(["findcase", "fc"],
310 "Find case matching mode",
314 "smart": "Case is significant when capital letters are typed",
315 "match": "Case is always significant",
316 "ignore": "Case is never significant"
320 options.add(["findflags", "ff"],
321 "Default flags for find invocations",
327 "r": "Perform a regular expression search",
328 "R": "Perform a plain string search",
329 "l": "Search only in links",
330 "L": "Search all text"
334 options.add(["incfind", "if"],
335 "Find a pattern incrementally as it is typed rather than awaiting c_<Return>",
343 * A fairly sophisticated typeahead-find replacement. It supports
344 * incremental find very much as the builtin component.
345 * Additionally, it supports several features impossible to
346 * implement using the standard component. Incremental finding
347 * works both forwards and backwards. Erasing characters during an
348 * incremental find moves the selection back to the first
349 * available match for the shorter term. The selection and viewport
350 * are restored when the find is canceled.
352 * Also, in addition to full support for frames and iframes, this
353 * implementation will begin finding from the position of the
354 * caret in the last active frame. This is contrary to the behavior
355 * of the builtin component, which always starts a find from the
356 * beginning of the first frame in the case of frameset documents,
357 * and cycles through all frames from beginning to end. This makes it
358 * impossible to choose the starting point of a find for such
359 * documents, and represents a major detriment to productivity where
360 * large amounts of data are concerned (e.g., for API documents).
362 var RangeFind = Class("RangeFind", {
363 init: function init(window, content, matchCase, backward, elementPath, regexp) {
364 this.window = util.weakReference(window);
365 this.content = content;
367 this.baseDocument = util.weakReference(this.content.document);
368 this.elementPath = elementPath || null;
369 this.reverse = Boolean(backward);
371 this.finder = services.Find();
372 this.matchCase = Boolean(matchCase);
373 this.regexp = Boolean(regexp);
377 this.highlighted = null;
378 this.selections = [];
379 this.lastString = "";
382 get store() overlay.getData(this.content.document, "buffer", Object),
384 get backward() this.finder.findBackwards,
385 set backward(val) this.finder.findBackwards = val,
387 get matchCase() this.finder.caseSensitive,
388 set matchCase(val) this.finder.caseSensitive = Boolean(val),
390 get findString() this.lastString,
392 get flags() this.matchCase ? "" : "i",
394 get selectedRange() {
395 let win = this.store.focusedFrame && this.store.focusedFrame.get() || this.content;
397 let selection = win.getSelection();
398 return (selection.rangeCount ? selection.getRangeAt(0) : this.ranges[0].range).cloneRange();
400 set selectedRange(range) {
401 this.range.selection.removeAllRanges();
402 this.range.selection.addRange(range);
403 this.range.selectionController.scrollSelectionIntoView(
404 this.range.selectionController.SELECTION_NORMAL, 0, false);
406 this.store.focusedFrame = util.weakReference(range.startContainer.ownerDocument.defaultView);
409 cancel: function cancel() {
410 this.purgeListeners();
412 this.range.deselect();
413 this.range.descroll();
417 compareRanges: function compareRanges(r1, r2) {
419 return this.backward ? r1.compareBoundaryPoints(r1.END_TO_START, r2)
420 : -r1.compareBoundaryPoints(r1.START_TO_END, r2);
428 findRange: function findRange(range) {
429 let doc = range.startContainer.ownerDocument;
430 let win = doc.defaultView;
431 let ranges = this.ranges.filter(r =>
432 r.window === win && RangeFind.sameDocument(r.range, range) && RangeFind.contains(r.range, range));
435 return ranges[ranges.length - 1];
439 findSubRanges: function findSubRanges(range) {
440 let doc = range.startContainer.ownerDocument;
441 for (let elem in this.elementPath(doc)) {
442 let r = RangeFind.nodeRange(elem);
443 if (RangeFind.contains(range, r))
448 focus: function focus() {
450 var node = DOM.XPath(RangeFind.selectNodePath,
451 this.lastRange.commonAncestorContainer).snapshotItem(0);
454 // Re-highlight collapsed selection
455 this.selectedRange = this.lastRange;
459 highlight: function highlight(clear) {
460 if (!clear && (!this.lastString || this.lastString == this.highlighted))
462 if (clear && !this.highlighted)
465 if (!clear && this.highlighted)
466 this.highlight(true);
469 this.selections.forEach(function (selection) {
470 selection.removeAllRanges();
472 this.selections = [];
473 this.highlighted = null;
476 this.selections = [];
477 let string = this.lastString;
478 for (let r in this.iter(string)) {
479 let controller = this.range.selectionController;
480 for (let node = r.startContainer; node; node = node.parentNode)
481 if (node instanceof Ci.nsIDOMNSEditableElement) {
482 controller = node.editor.selectionController;
486 let sel = controller.getSelection(Ci.nsISelectionController.SELECTION_FIND);
488 if (this.selections.indexOf(sel) < 0)
489 this.selections.push(sel);
491 this.highlighted = this.lastString;
493 this.selectedRange = this.lastRange;
498 indexIter: function indexIter(private_) {
499 let idx = this.range.index;
501 var groups = [util.range(idx + 1, 0, -1), util.range(this.ranges.length, idx, -1)];
503 var groups = [util.range(idx, this.ranges.length), util.range(0, idx + 1)];
505 for (let i in groups[0])
510 this.lastRange = null;
511 for (let i in groups[1])
516 iter: function iter(word) {
517 let saved = ["lastRange", "lastString", "range", "regexp"].map(s => [s, this[s]]);
520 let regexp = this.regexp && word != util.regexp.escape(word);
521 this.lastRange = null;
524 let re = RegExp(word, "gm" + this.flags);
525 for (this.range in array.iterValues(this.ranges)) {
526 for (let match in util.regexp.iterate(re, DOM.stringify(this.range.range, true))) {
527 let lastRange = this.lastRange;
528 if (res = this.find(null, this.reverse, true))
531 this.lastRange = lastRange;
536 this.range = this.ranges[0];
537 this.lastString = word;
538 while (res = this.find(null, this.reverse, true))
543 saved.forEach(([k, v]) => { this[k] = v; });
547 makeFrameList: function makeFrameList(win) {
553 function pushRange(start, end) {
555 if (r = RangeFind.Range(r, frames.length))
559 let doc = start.startContainer.ownerDocument;
561 let range = doc.createRange();
562 range.setStart(start.startContainer, start.startOffset);
563 range.setEnd(end.startContainer, end.startOffset);
565 if (!self.elementPath)
568 for (let r in self.findSubRanges(range))
572 let doc = win.document;
573 let pageRange = RangeFind[doc.body ? "nodeRange" : "nodeContents"](doc.body || doc.documentElement);
574 backup = backup || pageRange;
575 let pageStart = RangeFind.endpoint(pageRange, true);
576 let pageEnd = RangeFind.endpoint(pageRange, false);
578 for (let frame in array.iterValues(win.frames)) {
579 let range = doc.createRange();
580 if (DOM(frame.frameElement).style.visibility == "visible") {
581 range.selectNode(frame.frameElement);
582 pushRange(pageStart, RangeFind.endpoint(range, true));
583 pageStart = RangeFind.endpoint(range, false);
587 pushRange(pageStart, pageEnd);
589 let anonNodes = doc.getAnonymousNodes(doc.documentElement);
591 for (let [, elem] in iter(anonNodes)) {
592 let range = RangeFind.nodeContents(elem);
593 pushRange(RangeFind.endpoint(range, true), RangeFind.endpoint(range, false));
598 if (frames.length == 0)
599 frames[0] = RangeFind.Range(RangeFind.endpoint(backup, true), 0);
603 reset: function reset() {
604 this.ranges = this.makeFrameList(this.content);
606 this.startRange = this.selectedRange;
607 this.startRange.collapse(!this.reverse);
608 this.lastRange = this.selectedRange;
609 this.range = this.findRange(this.startRange) || this.ranges[0];
610 util.assert(this.range, "Null range", false);
611 this.ranges.first = this.range;
612 this.ranges.forEach(range => { range.save(); });
617 find: function find(pattern, reverse, private_) {
618 if (!private_ && this.lastRange && !RangeFind.equal(this.selectedRange, this.lastRange))
621 this.wrapped = false;
622 this.backward = reverse ? !this.reverse : this.reverse;
623 let again = pattern == null;
625 pattern = this.lastString;
627 pattern = pattern.toLowerCase();
629 if (!again && (pattern === "" || pattern.indexOf(this.lastString) !== 0 || this.backward)) {
631 this.range.deselect();
633 this.range.descroll();
634 this.lastRange = this.startRange;
635 this.range = this.ranges.first;
639 let regexp = this.regexp && word != util.regexp.escape(word);
650 var range = this.startRange;
652 for (let i in this.indexIter(private_)) {
653 if (!private_ && this.range.window != this.ranges[i].window && this.range.window != this.ranges[i].window.parent) {
654 this.range.descroll();
655 this.range.deselect();
657 this.range = this.ranges[i];
659 let start = RangeFind.sameDocument(this.lastRange, this.range.range) && this.range.intersects(this.lastRange) ?
660 RangeFind.endpoint(this.lastRange, !(again ^ this.backward)) :
661 RangeFind.endpoint(this.range.range, !this.backward);
663 if (this.backward && !again)
664 start = RangeFind.endpoint(this.startRange, false);
667 let range = this.range.range.cloneRange();
668 range[this.backward ? "setEnd" : "setStart"](
669 start.startContainer, start.startOffset);
670 range = DOM.stringify(range);
673 var match = RegExp(pattern, "m" + this.flags).exec(range);
675 match = RegExp("[^]*(?:" + pattern + ")", "m" + this.flags).exec(range);
677 match = RegExp(pattern + "$", this.flags).exec(match[0]);
679 if (!(match && match[0]))
684 var range = this.finder.Find(word, this.range.range, start, this.range.range);
685 if (range && DOM(range.commonAncestorContainer).isVisible)
690 this.lastRange = range.cloneRange();
692 this.lastString = pattern;
700 if (range && (!private_ || private_ < 0))
701 this.selectedRange = range;
705 get stale() this._stale || this.baseDocument.get() != this.content.document,
706 set stale(val) this._stale = val,
708 addListeners: function addListeners() {
709 for (let range in array.iterValues(this.ranges))
710 range.window.addEventListener("unload", this.closure.onUnload, true);
712 purgeListeners: function purgeListeners() {
713 for (let range in array.iterValues(this.ranges))
715 range.window.removeEventListener("unload", this.closure.onUnload, true);
717 catch (e if e.result === Cr.NS_ERROR_FAILURE) {}
719 onUnload: function onUnload(event) {
720 this.purgeListeners();
721 if (this.highlighted)
722 this.highlight(true);
726 Range: Class("RangeFind.Range", {
727 init: function init(range, index) {
731 this.document = range.startContainer.ownerDocument;
732 this.window = this.document.defaultView;
734 if (this.selection == null)
740 docShell: Class.Memoize(function () util.docShell(this.window)),
742 intersects: function (range) RangeFind.intersects(this.range, range),
744 save: function save() {
745 this.scroll = Point(this.window.pageXOffset, this.window.pageYOffset);
747 this.initialSelection = null;
748 if (this.selection.rangeCount)
749 this.initialSelection = this.selection.getRangeAt(0);
752 descroll: function descroll() {
753 this.window.scrollTo(this.scroll.x, this.scroll.y);
756 deselect: function deselect() {
757 if (this.selection) {
758 this.selection.removeAllRanges();
759 if (this.initialSelection)
760 this.selection.addRange(this.initialSelection);
764 get selectionController() this.docShell
765 .QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsISelectionDisplay)
766 .QueryInterface(Ci.nsISelectionController),
769 return this.selectionController.getSelection(Ci.nsISelectionController.SELECTION_NORMAL);
776 contains: function contains(range, r, quiet) {
778 return range.compareBoundaryPoints(range.START_TO_END, r) >= 0 &&
779 range.compareBoundaryPoints(range.END_TO_START, r) <= 0;
782 if (e.result != Cr.NS_ERROR_DOM_WRONG_DOCUMENT_ERR && !quiet)
783 util.reportError(e, true);
787 containsNode: function containsNode(range, n, quiet) n.ownerDocument && this.contains(range, RangeFind.nodeRange(n), quiet),
788 intersects: function intersects(range, r) {
790 return r.compareBoundaryPoints(range.START_TO_END, range) >= 0 &&
791 r.compareBoundaryPoints(range.END_TO_START, range) <= 0;
794 util.reportError(e, true);
798 endpoint: function endpoint(range, before) {
799 range = range.cloneRange();
800 range.collapse(before);
803 equal: function equal(r1, r2) {
805 return !r1.compareBoundaryPoints(r1.START_TO_START, r2) && !r1.compareBoundaryPoints(r1.END_TO_END, r2);
811 nodeContents: function nodeContents(node) {
812 let range = node.ownerDocument.createRange();
814 range.selectNodeContents(node);
819 nodeRange: function nodeRange(node) {
820 let range = node.ownerDocument.createRange();
822 range.selectNode(node);
827 sameDocument: function sameDocument(r1, r2) {
828 if (!(r1 && r2 && r1.endContainer.ownerDocument == r2.endContainer.ownerDocument))
831 r1.compareBoundaryPoints(r1.START_TO_START, r2);
833 catch (e if e.result == 0x80530004 /* NS_ERROR_DOM_WRONG_DOCUMENT_ERR */) {
838 selectNodePath: ["a", "xhtml:a", "*[@onclick]"].map(p => "ancestor-or-self::" + p).join(" | "),
839 union: function union(a, b) {
840 let start = a.compareBoundaryPoints(a.START_TO_START, b) < 0 ? a : b;
841 let end = a.compareBoundaryPoints(a.END_TO_END, b) > 0 ? a : b;
842 let res = start.cloneRange();
843 res.setEnd(end.endContainer, end.endOffset);
848 // catch(e){ if (typeof e === "string") e = Error(e); dump(e.fileName+":"+e.lineNumber+": "+e+"\n" + e.stack); }
852 // vim: set fdm=marker sw=4 sts=4 ts=8 et ft=javascript: