]> git.donarmstrong.com Git - dactyl.git/blob - common/modules/finder.jsm
5edd5904f4b0d657ec3cd754461338cca5ff703d
[dactyl.git] / common / modules / finder.jsm
1 // Copyright (c) 2008-2011 by Kris Maglione <maglione.k@gmail.com>
2 //
3 // This work is licensed for reuse under an MIT license. Details are
4 // given in the LICENSE.txt file included with this file.
5 "use strict";
6
7 Components.utils.import("resource://dactyl/bootstrap.jsm");
8 defineModule("finder", {
9     exports: ["RangeFind", "RangeFinder", "rangefinder"],
10     use: ["messages", "services", "util"]
11 }, this);
12
13 function equals(a, b) XPCNativeWrapper(a) == XPCNativeWrapper(b);
14
15 /** @instance rangefinder */
16 var RangeFinder = Module("rangefinder", {
17     Local: function (dactyl, modules, window) ({
18         init: function () {
19             this.dactyl = dactyl;
20             this.modules = modules;
21             this.window = window;
22             this.lastFindPattern = "";
23         },
24
25         get rangeFind() {
26             let find = modules.buffer.localStore.rangeFind;
27             if (find && find.stale || !isinstance(find, RangeFind))
28                 return this.rangeFind = null;
29             return find;
30         },
31         set rangeFind(val) modules.buffer.localStore.rangeFind = val
32     }),
33
34     get commandline() this.modules.commandline,
35     get modes() this.modules.modes,
36     get options() this.modules.options,
37
38     openPrompt: function (mode) {
39         this.commandline;
40         this.CommandMode(mode).open();
41
42         if (this.rangeFind && equals(this.rangeFind.window.get(), this.window))
43             this.rangeFind.reset();
44         this.find("", mode === this.modes.FIND_BACKWARD);
45     },
46
47     bootstrap: function (str, backward) {
48         let highlighted = this.rangeFind && this.rangeFind.highlighted;
49         let selections = this.rangeFind && this.rangeFind.selections;
50         let linksOnly = false;
51         let regexp = false;
52         let matchCase = this.options["findcase"] === "smart"  ? /[A-Z]/.test(str) :
53                         this.options["findcase"] === "ignore" ? false : true;
54
55         str = str.replace(/\\(.|$)/g, function (m, n1) {
56             if (n1 == "c")
57                 matchCase = false;
58             else if (n1 == "C")
59                 matchCase = true;
60             else if (n1 == "l")
61                 linksOnly = true;
62             else if (n1 == "L")
63                 linksOnly = false;
64             else if (n1 == "r")
65                 regexp = true;
66             else if (n1 == "R")
67                 regexp = false;
68             else
69                 return m;
70             return "";
71         });
72
73         // It's possible, with :tabdetach for instance, for the rangeFind to
74         // actually move from one window to another, which breaks things.
75         if (!this.rangeFind
76             || !equals(this.rangeFind.window.get(), this.window)
77             || linksOnly  != !!this.rangeFind.elementPath
78             || regexp     != this.rangeFind.regexp
79             || matchCase  != this.rangeFind.matchCase
80             || !!backward != this.rangeFind.reverse) {
81
82             if (this.rangeFind)
83                 this.rangeFind.cancel();
84             this.rangeFind = RangeFind(this.window, matchCase, backward,
85                                        linksOnly && this.options.get("hinttags").matcher,
86                                        regexp);
87             this.rangeFind.highlighted = highlighted;
88             this.rangeFind.selections = selections;
89         }
90         return this.lastFindPattern = str;
91     },
92
93     find: function (pattern, backwards) {
94         let str = this.bootstrap(pattern, backwards);
95         if (!this.rangeFind.find(str))
96             this.dactyl.echoerr(_("finder.notFound", pattern),
97                                 this.commandline.FORCE_SINGLELINE);
98
99         return this.rangeFind.found;
100     },
101
102     findAgain: function (reverse) {
103         if (!this.rangeFind)
104             this.find(this.lastFindPattern);
105         else if (!this.rangeFind.find(null, reverse))
106             this.dactyl.echoerr(_("finder.notFound", this.lastFindPattern),
107                                 this.commandline.FORCE_SINGLELINE);
108         else if (this.rangeFind.wrapped) {
109             let msg = this.rangeFind.backward ? _("finder.atTop")
110                                               : _("finder.atBottom");
111             this.commandline.echo(msg, "WarningMsg", this.commandline.APPEND_TO_MESSAGES
112                                                    | this.commandline.FORCE_SINGLELINE);
113         }
114         else
115             this.commandline.echo((this.rangeFind.backward ? "?" : "/") + this.lastFindPattern,
116                                   "Normal", this.commandline.FORCE_SINGLELINE);
117
118         if (this.options["hlfind"])
119             this.highlight();
120         this.rangeFind.focus();
121     },
122
123     onCancel: function () {
124         if (this.rangeFind)
125             this.rangeFind.cancel();
126     },
127
128     onChange: function (command) {
129         if (this.options["incfind"]) {
130             command = this.bootstrap(command);
131             this.rangeFind.find(command);
132         }
133     },
134
135     onSubmit: function (command) {
136         if (!this.options["incfind"] || !this.rangeFind || !this.rangeFind.found) {
137             this.clear();
138             this.find(command || this.lastFindPattern, this.modes.extended & this.modes.FIND_BACKWARD);
139         }
140
141         if (this.options["hlfind"])
142             this.highlight();
143         this.rangeFind.focus();
144     },
145
146     /**
147      * Highlights all occurrences of the last sought for string in the
148      * current buffer.
149      */
150     highlight: function () {
151         if (this.rangeFind)
152             this.rangeFind.highlight();
153     },
154
155     /**
156      * Clears all find highlighting.
157      */
158     clear: function () {
159         if (this.rangeFind)
160             this.rangeFind.highlight(true);
161     }
162 }, {
163 }, {
164     modes: function initModes(dactyl, modules, window) {
165         initModes.require("commandline");
166
167         const { modes } = modules;
168
169         modes.addMode("FIND", {
170             description: "Find mode, active when typing search input",
171             bases: [modes.COMMAND_LINE],
172         });
173         modes.addMode("FIND_FORWARD", {
174             description: "Forward Find mode, active when typing search input",
175             bases: [modes.FIND]
176         });
177         modes.addMode("FIND_BACKWARD", {
178             description: "Backward Find mode, active when typing search input",
179             bases: [modes.FIND]
180         });
181     },
182     commands: function initCommands(dactyl, modules, window) {
183         const { commands, rangefinder } = modules;
184         commands.add(["noh[lfind]"],
185             "Remove the find highlighting",
186             function () { rangefinder.clear(); },
187             { argCount: "0" });
188     },
189     commandline: function initCommandline(dactyl, modules, window) {
190         const { rangefinder } = modules;
191         rangefinder.CommandMode = Class("CommandFindMode", modules.CommandMode, {
192             init: function init(mode) {
193                 this.mode = mode;
194                 init.supercall(this);
195             },
196
197             historyKey: "find",
198
199             get prompt() this.mode === modules.modes.FIND_BACKWARD ? "?" : "/",
200
201             get onCancel() modules.rangefinder.closure.onCancel,
202             get onChange() modules.rangefinder.closure.onChange,
203             get onSubmit() modules.rangefinder.closure.onSubmit
204         });
205     },
206     mappings: function (dactyl, modules, window) {
207         const { buffer, config, mappings, modes, rangefinder } = modules;
208         var myModes = config.browserModes.concat([modes.CARET]);
209
210         mappings.add(myModes,
211             ["/"], "Find a pattern starting at the current caret position",
212             function () { rangefinder.openPrompt(modes.FIND_FORWARD); });
213
214         mappings.add(myModes,
215             ["?"], "Find a pattern backward of the current caret position",
216             function () { rangefinder.openPrompt(modes.FIND_BACKWARD); });
217
218         mappings.add(myModes,
219             ["n"], "Find next",
220             function () { rangefinder.findAgain(false); });
221
222         mappings.add(myModes,
223             ["N"], "Find previous",
224             function () { rangefinder.findAgain(true); });
225
226         mappings.add(myModes.concat([modes.CARET, modes.TEXT_EDIT]), ["*"],
227             "Find word under cursor",
228             function () {
229                 rangefinder.find(buffer.getCurrentWord(), false);
230                 rangefinder.findAgain();
231             });
232
233         mappings.add(myModes.concat([modes.CARET, modes.TEXT_EDIT]), ["#"],
234             "Find word under cursor backwards",
235             function () {
236                 rangefinder.find(buffer.getCurrentWord(), true);
237                 rangefinder.findAgain();
238             });
239
240     },
241     options: function (dactyl, modules, window) {
242         const { options, rangefinder } = modules;
243         const { prefs } = require("prefs");
244
245         // prefs.safeSet("accessibility.typeaheadfind.autostart", false);
246         // The above should be sufficient, but: https://bugzilla.mozilla.org/show_bug.cgi?id=348187
247         prefs.safeSet("accessibility.typeaheadfind", false);
248
249         options.add(["hlfind", "hlf"],
250             "Highlight all /find pattern matches on the current page after submission",
251             "boolean", false, {
252                 setter: function (value) {
253                     rangefinder[value ? "highlight" : "clear"]();
254                     return value;
255                 }
256             });
257
258         options.add(["findcase", "fc"],
259             "Find case matching mode",
260             "string", "smart",
261             {
262                 values: {
263                     "smart": "Case is significant when capital letters are typed",
264                     "match": "Case is always significant",
265                     "ignore": "Case is never significant"
266                 }
267             });
268
269         options.add(["incfind", "if"],
270             "Find a pattern incrementally as it is typed rather than awaiting <Return>",
271             "boolean", true);
272     }
273 });
274
275 /**
276  * @class RangeFind
277  *
278  * A fairly sophisticated typeahead-find replacement. It supports
279  * incremental find very much as the builtin component.
280  * Additionally, it supports several features impossible to
281  * implement using the standard component. Incremental finding
282  * works both forwards and backwards. Erasing characters during an
283  * incremental find moves the selection back to the first
284  * available match for the shorter term. The selection and viewport
285  * are restored when the find is canceled.
286  *
287  * Also, in addition to full support for frames and iframes, this
288  * implementation will begin finding from the position of the
289  * caret in the last active frame. This is contrary to the behavior
290  * of the builtin component, which always starts a find from the
291  * beginning of the first frame in the case of frameset documents,
292  * and cycles through all frames from beginning to end. This makes it
293  * impossible to choose the starting point of a find for such
294  * documents, and represents a major detriment to productivity where
295  * large amounts of data are concerned (e.g., for API documents).
296  */
297 var RangeFind = Class("RangeFind", {
298     init: function init(window, matchCase, backward, elementPath, regexp) {
299         this.window = Cu.getWeakReference(window);
300         this.content = window.content;
301
302         this.baseDocument = Cu.getWeakReference(this.content.document);
303         this.elementPath = elementPath || null;
304         this.reverse = Boolean(backward);
305
306         this.finder = services.Find();
307         this.matchCase = Boolean(matchCase);
308         this.regexp = Boolean(regexp);
309
310         this.reset();
311
312         this.highlighted = null;
313         this.selections = [];
314         this.lastString = "";
315     },
316
317     get store() this.content.document.dactylStore = this.content.document.dactylStore || {},
318
319     get backward() this.finder.findBackwards,
320
321     get matchCase() this.finder.caseSensitive,
322     set matchCase(val) this.finder.caseSensitive = Boolean(val),
323
324     get regexp() this.finder.regularExpression || false,
325     set regexp(val) {
326         try {
327             return this.finder.regularExpression = Boolean(val);
328         }
329         catch (e) {
330             return false;
331         }
332     },
333
334     get findString() this.lastString,
335
336     get selectedRange() {
337         let win = this.store.focusedFrame && this.store.focusedFrame.get() || this.content;
338
339         let selection = win.getSelection();
340         return (selection.rangeCount ? selection.getRangeAt(0) : this.ranges[0].range).cloneRange();
341     },
342     set selectedRange(range) {
343         this.range.selection.removeAllRanges();
344         this.range.selection.addRange(range);
345         this.range.selectionController.scrollSelectionIntoView(
346             this.range.selectionController.SELECTION_NORMAL, 0, false);
347
348         this.store.focusedFrame = Cu.getWeakReference(range.startContainer.ownerDocument.defaultView);
349     },
350
351     cancel: function cancel() {
352         this.purgeListeners();
353         this.range.deselect();
354         this.range.descroll();
355     },
356
357     compareRanges: function compareRanges(r1, r2) {
358         try {
359             return this.backward ?  r1.compareBoundaryPoints(r1.END_TO_START, r2)
360                                  : -r1.compareBoundaryPoints(r1.START_TO_END, r2);
361         }
362         catch (e) {
363             util.reportError(e);
364             return 0;
365         }
366     },
367
368     findRange: function findRange(range) {
369         let doc = range.startContainer.ownerDocument;
370         let win = doc.defaultView;
371         let ranges = this.ranges.filter(function (r)
372             r.window === win && RangeFind.contains(r.range, range));
373
374         if (this.backward)
375             return ranges[ranges.length - 1];
376         return ranges[0];
377     },
378
379     findSubRanges: function findSubRanges(range) {
380         let doc = range.startContainer.ownerDocument;
381         for (let elem in this.elementPath(doc)) {
382             let r = RangeFind.nodeRange(elem);
383             if (RangeFind.contains(range, r))
384                 yield r;
385         }
386     },
387
388     focus: function focus() {
389         if (this.lastRange)
390             var node = util.evaluateXPath(RangeFind.selectNodePath,
391                                           this.lastRange.commonAncestorContainer).snapshotItem(0);
392         if (node) {
393             node.focus()
394             // Re-highlight collapsed selection
395             this.selectedRange = this.lastRange;
396         }
397     },
398
399     highlight: function highlight(clear) {
400         if (!clear && (!this.lastString || this.lastString == this.highlighted))
401             return;
402         if (clear && !this.highlighted)
403             return;
404
405         if (!clear && this.highlighted)
406             this.highlight(true);
407
408         if (clear) {
409             this.selections.forEach(function (selection) {
410                 selection.removeAllRanges();
411             });
412             this.selections = [];
413             this.highlighted = null;
414         }
415         else {
416             this.selections = [];
417             let string = this.lastString;
418             for (let r in this.iter(string)) {
419                 let controller = this.range.selectionController;
420                 for (let node = r.startContainer; node; node = node.parentNode)
421                     if (node instanceof Ci.nsIDOMNSEditableElement) {
422                         controller = node.editor.selectionController;
423                         break;
424                     }
425
426                 let sel = controller.getSelection(Ci.nsISelectionController.SELECTION_FIND);
427                 sel.addRange(r);
428                 if (this.selections.indexOf(sel) < 0)
429                     this.selections.push(sel);
430             }
431             this.highlighted = this.lastString;
432             if (this.lastRange)
433                 this.selectedRange = this.lastRange;
434             this.addListeners();
435         }
436     },
437
438     indexIter: function (private_) {
439         let idx = this.range.index;
440         if (this.backward)
441             var groups = [util.range(idx + 1, 0, -1), util.range(this.ranges.length, idx, -1)];
442         else
443             var groups = [util.range(idx, this.ranges.length), util.range(0, idx + 1)];
444
445         for (let i in groups[0])
446             yield i;
447
448         if (!private_) {
449             this.wrapped = true;
450             this.lastRange = null;
451             for (let i in groups[1])
452                 yield i;
453         }
454     },
455
456     iter: function (word) {
457         let saved = ["lastRange", "lastString", "range"].map(function (s) [s, this[s]], this);
458         try {
459             this.range = this.ranges[0];
460             this.lastRange = null;
461             this.lastString = word;
462             var res;
463             while (res = this.find(null, this.reverse, true))
464                 yield res;
465         }
466         finally {
467             saved.forEach(function ([k, v]) this[k] = v, this);
468         }
469     },
470
471     makeFrameList: function (win) {
472         const self = this;
473         win = win.top;
474         let frames = [];
475         let backup = null;
476
477         function pushRange(start, end) {
478             function push(r) {
479                 if (r = RangeFind.Range(r, frames.length))
480                     frames.push(r);
481             }
482
483             let range = start.startContainer.ownerDocument.createRange();
484             range.setStart(start.startContainer, start.startOffset);
485             range.setEnd(end.startContainer, end.startOffset);
486
487             if (!self.elementPath)
488                 push(range);
489             else
490                 for (let r in self.findSubRanges(range))
491                     push(r);
492         }
493         function rec(win) {
494             let doc = win.document;
495             let pageRange = RangeFind.nodeRange(doc.body || doc.documentElement.lastChild);
496             backup = backup || pageRange;
497             let pageStart = RangeFind.endpoint(pageRange, true);
498             let pageEnd = RangeFind.endpoint(pageRange, false);
499
500             for (let frame in array.iterValues(win.frames)) {
501                 let range = doc.createRange();
502                 if (util.computedStyle(frame.frameElement).visibility == "visible") {
503                     range.selectNode(frame.frameElement);
504                     pushRange(pageStart, RangeFind.endpoint(range, true));
505                     pageStart = RangeFind.endpoint(range, false);
506                     rec(frame);
507                 }
508             }
509             pushRange(pageStart, pageEnd);
510         }
511         rec(win);
512         if (frames.length == 0)
513             frames[0] = RangeFind.Range(RangeFind.endpoint(backup, true), 0);
514         return frames;
515     },
516
517     reset: function () {
518         this.ranges = this.makeFrameList(this.content);
519
520         this.startRange = this.selectedRange;
521         this.startRange.collapse(!this.reverse);
522         this.lastRange = this.selectedRange;
523         this.range = this.findRange(this.startRange);
524         this.ranges.first = this.range;
525         this.ranges.forEach(function (range) range.save());
526         this.forward = null;
527         this.found = false;
528     },
529
530     // This doesn't work yet.
531     resetCaret: function () {
532         let equal = RangeFind.equal;
533         let selection = this.win.getSelection();
534         if (selection.rangeCount == 0)
535             selection.addRange(this.pageStart);
536         function getLines() {
537             let orig = selection.getRangeAt(0);
538             function getRanges(forward) {
539                 selection.removeAllRanges();
540                 selection.addRange(orig);
541                 let cur = orig;
542                 while (true) {
543                     var last = cur;
544                     this.sel.lineMove(forward, false);
545                     cur = selection.getRangeAt(0);
546                     if (equal(cur, last))
547                         break;
548                     yield cur;
549                 }
550             }
551             yield orig;
552             for (let range in getRanges(true))
553                 yield range;
554             for (let range in getRanges(false))
555                 yield range;
556         }
557         for (let range in getLines()) {
558             if (this.sel.checkVisibility(range.startContainer, range.startOffset, range.startOffset))
559                 return range;
560         }
561         return null;
562     },
563
564     find: function (word, reverse, private_) {
565         if (!private_ && this.lastRange && !RangeFind.equal(this.selectedRange, this.lastRange))
566             this.reset();
567
568         this.wrapped = false;
569         this.finder.findBackwards = reverse ? !this.reverse : this.reverse;
570         let again = word == null;
571         if (again)
572             word = this.lastString;
573         if (!this.matchCase)
574             word = word.toLowerCase();
575
576         if (!again && (word === "" || word.indexOf(this.lastString) !== 0 || this.backward)) {
577             if (!private_)
578                 this.range.deselect();
579             if (word === "")
580                 this.range.descroll();
581             this.lastRange = this.startRange;
582             this.range = this.ranges.first;
583         }
584
585         if (word == "")
586             var range = this.startRange;
587         else
588             for (let i in this.indexIter(private_)) {
589                 if (!private_ && this.range.window != this.ranges[i].window && this.range.window != this.ranges[i].window.parent) {
590                     this.range.descroll();
591                     this.range.deselect();
592                 }
593                 this.range = this.ranges[i];
594
595                 let start = RangeFind.sameDocument(this.lastRange, this.range.range) && this.range.intersects(this.lastRange) ?
596                                 RangeFind.endpoint(this.lastRange, !(again ^ this.backward)) :
597                                 RangeFind.endpoint(this.range.range, !this.backward);
598
599                 if (this.backward && !again)
600                     start = RangeFind.endpoint(this.startRange, false);
601
602                 var range = this.finder.Find(word, this.range.range, start, this.range.range);
603                 if (range)
604                     break;
605             }
606
607         if (range)
608             this.lastRange = range.cloneRange();
609         if (!private_) {
610             this.lastString = word;
611             if (range == null) {
612                 this.cancel();
613                 this.found = false;
614                 return null;
615             }
616             this.found = true;
617         }
618         if (range && (!private_ || private_ < 0))
619             this.selectedRange = range;
620         return range;
621     },
622
623     get stale() this._stale || this.baseDocument.get() != this.content.document,
624     set stale(val) this._stale = val,
625
626     addListeners: function () {
627         for (let range in array.iterValues(this.ranges))
628             range.window.addEventListener("unload", this.closure.onUnload, true);
629     },
630     purgeListeners: function () {
631         for (let range in array.iterValues(this.ranges))
632             try {
633                 range.window.removeEventListener("unload", this.closure.onUnload, true);
634             }
635             catch (e if e.result === Cr.NS_ERROR_FAILURE) {}
636     },
637     onUnload: function (event) {
638         this.purgeListeners();
639         if (this.highlighted)
640             this.highlight(true);
641         this.stale = true;
642     }
643 }, {
644     Range: Class("RangeFind.Range", {
645         init: function (range, index) {
646             this.index = index;
647
648             this.range = range;
649             this.document = range.startContainer.ownerDocument;
650             this.window = this.document.defaultView;
651             this.docShell = this.window.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIWebNavigation)
652                                        .QueryInterface(Ci.nsIDocShell);
653
654             if (this.selection == null)
655                 return false;
656
657             this.save();
658         },
659
660         intersects: function (range) RangeFind.intersects(this.range, range),
661
662         save: function () {
663             this.scroll = Point(this.window.pageXOffset, this.window.pageYOffset);
664
665             this.initialSelection = null;
666             if (this.selection.rangeCount)
667                 this.initialSelection = this.selection.getRangeAt(0);
668         },
669
670         descroll: function () {
671             this.window.scrollTo(this.scroll.x, this.scroll.y);
672         },
673
674         deselect: function () {
675             if (this.selection) {
676                 this.selection.removeAllRanges();
677                 if (this.initialSelection)
678                     this.selection.addRange(this.initialSelection);
679             }
680         },
681
682         get selectionController() this.docShell
683                     .QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsISelectionDisplay)
684                     .QueryInterface(Ci.nsISelectionController),
685         get selection() {
686             try {
687                 return this.selectionController.getSelection(Ci.nsISelectionController.SELECTION_NORMAL);
688             }
689             catch (e) {
690                 return null;
691             }
692         }
693     }),
694     contains: function (range, r) {
695         try {
696             return range.compareBoundaryPoints(range.START_TO_END, r) >= 0 &&
697                    range.compareBoundaryPoints(range.END_TO_START, r) <= 0;
698         }
699         catch (e) {
700             util.reportError(e, true);
701             return false;
702         }
703     },
704     intersects: function (range, r) {
705         try {
706             return r.compareBoundaryPoints(range.START_TO_END, range) >= 0 &&
707                    r.compareBoundaryPoints(range.END_TO_START, range) <= 0;
708         }
709         catch (e) {
710             util.reportError(e, true);
711             return false;
712         }
713     },
714     endpoint: function (range, before) {
715         range = range.cloneRange();
716         range.collapse(before);
717         return range;
718     },
719     equal: function (r1, r2) {
720         try {
721             return !r1.compareBoundaryPoints(r1.START_TO_START, r2) && !r1.compareBoundaryPoints(r1.END_TO_END, r2);
722         }
723         catch (e) {
724             return false;
725         }
726     },
727     nodeRange: function (node) {
728         let range = node.ownerDocument.createRange();
729         try {
730             range.selectNode(node);
731         }
732         catch (e) {}
733         return range;
734     },
735     sameDocument: function (r1, r2) r1 && r2 && r1.endContainer.ownerDocument == r2.endContainer.ownerDocument,
736     selectNodePath: ["a", "xhtml:a", "*[@onclick]"].map(function (p) "ancestor-or-self::" + p).join(" | ")
737 });
738
739 endModule();
740
741 // vim: set fdm=marker sw=4 ts=4 et ft=javascript: