call_on_focused_field, modify_region: support richedit frames
[conkeror.git] / modules / hints.js
blobce9a3f1c96f523ba75e7e1a1735ed54e2553ce2c
1 /**
2  * (C) Copyright 2007-2008 Jeremy Maitin-Shepard
3  * (C) Copyright 2009-2010 John J. Foerch
4  *
5  * Portions of this file are derived from Vimperator,
6  * (C) Copyright 2006-2007 Martin Stubenschrott.
7  *
8  * Use, modification, and distribution are subject to the terms specified in the
9  * COPYING file.
10 **/
12 in_module(null);
14 define_variable("active_img_hint_background_color", "#88FF00",
15     "Color for the active image hint background.");
17 define_variable("img_hint_background_color", "yellow",
18     "Color for inactive image hint backgrounds.");
20 define_variable("active_hint_background_color", "#88FF00",
21     "Color for the active hint background.");
23 define_variable("hint_background_color", "yellow",
24     "Color for the inactive hint.");
27 /**
28  * Register hints style sheet
29  */
30 const hints_stylesheet = "chrome://conkeror-gui/content/hints.css";
31 register_user_stylesheet(hints_stylesheet);
34 function hints_simple_text_match (text, pattern) {
35     var pos = text.indexOf(pattern);
36     if (pos == -1)
37         return false;
38     return [pos, pos + pattern.length];
41 define_variable('hints_text_match', hints_simple_text_match,
42     "A function which takes a string and a pattern (another string) "+
43     "and returns an array of [start, end] indices if the pattern was "+
44     "found in the string, or false if it was not.");
47 /**
48  *   In the hints interaction, a node can be selected either by typing
49  * the number of its associated hint, or by typing substrings of the
50  * text content of the node.  In the case of selecting by text
51  * content, multiple substrings can be given by separating them with
52  * spaces.
53  */
54 function hint_manager (window, xpath_expr, focused_frame, focused_element) {
55     this.window = window;
56     this.hints = [];
57     this.valid_hints = [];
58     this.xpath_expr = xpath_expr;
59     this.focused_frame = focused_frame;
60     this.focused_element = focused_element;
61     this.last_selected_hint = null;
63     // Generate
64     this.generate_hints();
66 hint_manager.prototype = {
67     constructor: hint_manager,
68     current_hint_string: "",
69     current_hint_number: -1,
71     /**
72      * Create an initially hidden hint span element absolutely
73      * positioned over each element that matches
74      * hint_xpath_expression.  This is done recursively for all frames
75      * and iframes.  Information about the resulting hints are also
76      * stored in the hints array.
77      */
78     generate_hints: function () {
79         var topwin = this.window;
80         var top_height = topwin.innerHeight;
81         var top_width = topwin.innerWidth;
82         var hints = this.hints;
83         var xpath_expr = this.xpath_expr;
84         var focused_frame_hint = null, focused_element_hint = null;
85         var focused_frame = this.focused_frame;
86         var focused_element = this.focused_element;
88         function helper (window, offsetX, offsetY) {
89             var win_height = window.height;
90             var win_width = window.width;
92             // Bounds
93             var minX = offsetX < 0 ? -offsetX : 0;
94             var minY = offsetY < 0 ? -offsetY : 0;
95             var maxX = offsetX + win_width > top_width ? top_width - offsetX : top_width;
96             var maxY = offsetY + win_height > top_height ? top_height - offsetY : top_height;
98             var scrollX = window.scrollX;
99             var scrollY = window.scrollY;
101             var doc = window.document;
102             var res = doc.evaluate(xpath_expr, doc, xpath_lookup_namespace,
103                                    Ci.nsIDOMXPathResult.UNORDERED_NODE_SNAPSHOT_TYPE,
104                                    null /* existing results */);
106             var base_node = doc.createElementNS(XHTML_NS, "span");
107             base_node.className = "__conkeror_hint";
109             var fragment = doc.createDocumentFragment();
110             var rect, elem, text, node, show_text;
111             for (var j = 0; j < res.snapshotLength; j++) {
112                 elem = res.snapshotItem(j);
113                 rect = elem.getBoundingClientRect();
114                 if (elem instanceof Ci.nsIDOMHTMLAreaElement) {
115                     rect = { top: rect.top,
116                              left: rect.left,
117                              bottom: rect.bottom,
118                              right: rect.right };
119                     try {
120                         var coords = elem.getAttribute("coords")
121                             .match(/^\D*(-?\d+)\D+(-?\d+)/);
122                         if (coords.length == 3) {
123                             rect.left += parseInt(coords[1]);
124                             rect.top += parseInt(coords[2]);
125                         }
126                     } catch (e) {}
127                 }
128                 if (!rect || rect.left > maxX || rect.right < minX || rect.top > maxY || rect.bottom < minY)
129                     continue;
130                 let style = topwin.getComputedStyle(elem, "");
131                 if (style.display == "none" || style.visibility == "hidden")
132                     continue;
133                 if (! (elem instanceof Ci.nsIDOMHTMLAreaElement))
134                     rect = elem.getClientRects()[0];
135                 if (!rect)
136                     continue;
137                 show_text = false;
138                 if (elem instanceof Ci.nsIDOMHTMLInputElement || elem instanceof Ci.nsIDOMHTMLTextAreaElement)
139                     text = elem.value;
140                 else if (elem instanceof Ci.nsIDOMHTMLSelectElement) {
141                     if (elem.selectedIndex >= 0)
142                         text = elem.item(elem.selectedIndex).text;
143                     else
144                         text = "";
145                 } else if (elem instanceof Ci.nsIDOMHTMLFrameElement) {
146                     text = elem.name ? elem.name : "";
147                 } else if (/^\s*$/.test(elem.textContent) &&
148                            elem.childNodes.length == 1 &&
149                            elem.childNodes.item(0) instanceof Ci.nsIDOMHTMLImageElement) {
150                     text = elem.childNodes.item(0).alt;
151                     show_text = true;
152                 } else
153                     text = elem.textContent;
155                 node = base_node.cloneNode(true);
156                 node.style.left = (rect.left + scrollX) + "px";
157                 node.style.top = (rect.top + scrollY) + "px";
158                 fragment.appendChild(node);
160                 let hint = { text: text,
161                              ltext: text.toLowerCase(),
162                              elem: elem,
163                              hint: node,
164                              img_hint: null,
165                              visible: false,
166                              show_text: show_text };
167                 if (elem.style) {
168                     hint.saved_color = elem.style.color;
169                     hint.saved_bgcolor = elem.style.backgroundColor;
170                 }
171                 hints.push(hint);
173                 if (elem == focused_element)
174                     focused_element_hint = hint;
175                 else if ((elem instanceof Ci.nsIDOMHTMLFrameElement ||
176                           elem instanceof Ci.nsIDOMHTMLIFrameElement) &&
177                          elem.contentWindow == focused_frame)
178                     focused_frame_hint = hint;
179             }
180             doc.documentElement.appendChild(fragment);
182             /* Recurse into any IFRAME or FRAME elements */
183             var frametag = "frame";
184             while (true) {
185                 var frames = doc.getElementsByTagName(frametag);
186                 for (var i = 0, nframes = frames.length; i < nframes; ++i) {
187                     elem = frames[i];
188                     rect = elem.getBoundingClientRect();
189                     if (!rect || rect.left > maxX || rect.right < minX || rect.top > maxY || rect.bottom < minY)
190                         continue;
191                     helper(elem.contentWindow, offsetX + rect.left, offsetY + rect.top);
192                 }
193                 if (frametag == "frame") frametag = "iframe"; else break;
194             }
195         }
196         helper(topwin, 0, 0);
197         this.last_selected_hint = focused_element_hint || focused_frame_hint;
198     },
200     /* Updates valid_hints and also re-numbers and re-displays all hints. */
201     update_valid_hints: function () {
202         this.valid_hints = [];
203         var active_number = this.current_hint_number;
204         var tokens = this.current_hint_string.split(" ");
205         var case_sensitive = (this.current_hint_string !=
206                               this.current_hint_string.toLowerCase());
207         var rect, text, img_hint, doc, scrollX, scrollY;
208     outer:
209         for (var i = 0, h; (h = this.hints[i]); ++i) {
210             if (case_sensitive)
211                 text = h.text;
212             else
213                 text = h.ltext;
214             for (var j = 0, ntokens = tokens.length; j < ntokens; ++j) {
215                 if (! hints_text_match(text, tokens[j])) {
216                     if (h.visible) {
217                         h.visible = false;
218                         h.hint.style.display = "none";
219                         if (h.img_hint)
220                             h.img_hint.style.display = "none";
221                         if (h.saved_color != null) {
222                             h.elem.style.backgroundColor = h.saved_bgcolor;
223                             h.elem.style.color = h.saved_color;
224                         }
225                     }
226                     continue outer;
227                 }
228             }
230             var cur_number = this.valid_hints.length + 1;
231             h.visible = true;
233             if (h == this.last_selected_hint && active_number == -1)
234                 this.current_hint_number = active_number = cur_number;
236             var img_elem = null;
238             if (text == "" && h.elem.firstChild &&
239                 h.elem.firstChild instanceof Ci.nsIDOMHTMLImageElement)
240                 img_elem = h.elem.firstChild;
241             else if (h.elem instanceof Ci.nsIDOMHTMLImageElement)
242                 img_elem = h.elem;
244             if (img_elem) {
245                 if (!h.img_hint) {
246                     rect = img_elem.getBoundingClientRect();
247                     if (rect) {
248                         doc = h.elem.ownerDocument;
249                         scrollX = doc.defaultView.scrollX;
250                         scrollY = doc.defaultView.scrollY;
251                         img_hint = doc.createElementNS(XHTML_NS, "span");
252                         img_hint.className = "__conkeror_img_hint";
253                         img_hint.style.left = (rect.left + scrollX) + "px";
254                         img_hint.style.top = (rect.top + scrollY) + "px";
255                         img_hint.style.width = (rect.right - rect.left) + "px";
256                         img_hint.style.height = (rect.bottom - rect.top) + "px";
257                         h.img_hint = img_hint;
258                         doc.documentElement.appendChild(img_hint);
259                     } else
260                         img_elem = null;
261                 }
262                 if (img_elem) {
263                     var bgcolor = (active_number == cur_number) ?
264                         active_img_hint_background_color : img_hint_background_color;
265                     h.img_hint.style.backgroundColor = bgcolor;
266                     h.img_hint.style.display = "inline";
267                 }
268             }
270             if (!h.img_hint && h.elem.style)
271                 h.elem.style.backgroundColor = (active_number == cur_number) ?
272                     active_hint_background_color : hint_background_color;
274             if (h.elem.style)
275                 h.elem.style.color = "black";
277             var label = "" + cur_number;
278             if (h.elem instanceof Ci.nsIDOMHTMLFrameElement) {
279                 label +=  " " + text;
280             } else if (h.show_text && !/^\s*$/.test(text)) {
281                 let substrs = [[0,4]];
282                 for (j = 0; j < ntokens; ++j) {
283                     let m = hints_text_match(text, tokens[j]);
284                     if (m == false) continue;
285                     splice_range(substrs, m[0], m[1] + 2);
286                 }
287                 label += " " + substrs.map(function (x) {
288                     return text.substring(x[0],Math.min(x[1], text.length));
289                 }).join("..") + "..";
290             }
291             h.hint.textContent = label;
292             h.hint.style.display = "inline";
293             this.valid_hints.push(h);
294         }
296         if (active_number == -1)
297             this.select_hint(1);
298     },
300     select_hint: function (index) {
301         var old_index = this.current_hint_number;
302         if (index == old_index)
303             return;
304         var vh = this.valid_hints;
305         if (old_index >= 1 && old_index <= vh.length) {
306             var h = vh[old_index - 1];
307             if (h.img_hint)
308                 h.img_hint.style.backgroundColor = img_hint_background_color;
309             if (h.elem.style)
310                 h.elem.style.backgroundColor = hint_background_color;
311         }
312         this.current_hint_number = index;
313         if (index >= 1 && index <= vh.length) {
314             var h = vh[index - 1];
315             if (h.img_hint)
316                 h.img_hint.style.backgroundColor = active_img_hint_background_color;
317             if (h.elem.style)
318                 h.elem.style.backgroundColor = active_hint_background_color;
319             this.last_selected_hint = h;
320         }
321     },
323     hide_hints: function () {
324         for (var i = 0, nhints = this.hints.length; i < nhints; ++i) {
325             var h = this.hints[i];
326             if (h.visible) {
327                 h.visible = false;
328                 if (h.saved_color != null) {
329                     h.elem.style.color = h.saved_color;
330                     h.elem.style.backgroundColor = h.saved_bgcolor;
331                 }
332                 if (h.img_hint)
333                     h.img_hint.style.display = "none";
334                 h.hint.style.display = "none";
335             }
336         }
337     },
339     remove: function () {
340         for (var i = 0, nhints = this.hints.length; i < nhints; ++i) {
341             var h = this.hints[i];
342             if (h.visible && h.saved_color != null) {
343                 h.elem.style.color = h.saved_color;
344                 h.elem.style.backgroundColor = h.saved_bgcolor;
345             }
346             if (h.img_hint)
347                 h.img_hint.parentNode.removeChild(h.img_hint);
348             h.hint.parentNode.removeChild(h.hint);
349         }
350         this.hints = [];
351         this.valid_hints = [];
352     }
355 /* Show panel with currently selected URL. */
356 function hints_url_panel (hints, window) {
357     var g = new dom_generator(window.document, XUL_NS);
359     var p = g.element("hbox", "class", "panel url", "flex", "0");
360     g.element("label", p, "value", "URL:", "class", "url-panel-label");
361     var url_value = g.element("label", p, "class", "url-panel-value",
362                               "crop", "end", "flex", "1");
363     window.minibuffer.insert_before(p);
365     p.update = function () {
366         url_value.value = "";
367         if (hints.manager && hints.manager.last_selected_hint) {
368             var spec;
369             try {
370                 spec = load_spec(hints.manager.last_selected_hint.elem);
371             } catch (e) {}
372             if (spec) {
373                 var uri = load_spec_uri_string(spec);
374                 if (uri) url_value.value = uri;
375             }
376         }
377     };
379     p.destroy = function () {
380         this.parentNode.removeChild(this);
381     };
383     return p;
386 define_variable("hints_display_url_panel", false,
387     "When selecting a hint, the URL can be displayed in a panel above "+
388     "the minibuffer.  This is useful for confirming that the correct "+
389     "link is selected and that the URL is not evil.  This option is "+
390     "most useful when hints_auto_exit_delay is long or disabled.");
393  * keyword arguments:
395  * $prompt
396  * $callback
397  * $abort_callback
398  */
399 define_keywords("$keymap", "$auto", "$hint_xpath_expression", "$multiple");
400 function hints_minibuffer_state (minibuffer, continuation, buffer) {
401     keywords(arguments, $keymap = hint_keymap, $auto);
402     basic_minibuffer_state.call(this, minibuffer, $prompt = arguments.$prompt,
403                                 $keymap = arguments.$keymap);
404     if (hints_display_url_panel)
405         this.url_panel = hints_url_panel(this, buffer.window);
406     this.original_prompt = arguments.$prompt;
407     this.continuation = continuation;
408     this.auto_exit = arguments.$auto ? true : false;
409     this.xpath_expr = arguments.$hint_xpath_expression;
410     this.auto_exit_timer_ID = null;
411     this.multiple = arguments.$multiple;
412     this.focused_element = buffer.focused_element;
413     this.focused_frame = buffer.focused_frame;
415 hints_minibuffer_state.prototype = {
416     constructor: hints_minibuffer_state,
417     __proto__: basic_minibuffer_state.prototype,
418     manager: null,
419     typed_string: "",
420     typed_number: "",
421     load: function () {
422         basic_minibuffer_state.prototype.load.call(this);
423         if (!this.manager) {
424             var buf = this.minibuffer.window.buffers.current;
425             this.manager = new hint_manager(buf.top_frame, this.xpath_expr,
426                                             this.focused_frame, this.focused_element);
427         }
428         this.manager.update_valid_hints();
429         if (this.url_panel)
430             this.url_panel.update();
431     },
432     clear_auto_exit_timer: function () {
433         var window = this.minibuffer.window;
434         if (this.auto_exit_timer_ID != null) {
435             window.clearTimeout(this.auto_exit_timer_ID);
436             this.auto_exit_timer_ID = null;
437         }
438     },
439     unload: function () {
440         this.clear_auto_exit_timer();
441         this.manager.hide_hints();
442         basic_minibuffer_state.prototype.unload.call(this);
443     },
444     destroy: function () {
445         this.clear_auto_exit_timer();
446         this.manager.remove();
447         if (this.url_panel)
448             this.url_panel.destroy();
449         basic_minibuffer_state.prototype.destroy.call(this);
450     },
451     update_minibuffer: function (m) {
452         if (this.typed_number.length > 0)
453             m.prompt = this.original_prompt + " #" + this.typed_number;
454         else
455             m.prompt = this.original_prompt;
456         if (this.url_panel)
457             this.url_panel.update();
458     },
460     handle_auto_exit: function (ambiguous) {
461         var window = this.minibuffer.window;
462         var num = this.manager.current_hint_number;
463         if (!this.auto_exit)
464             return;
465         let s = this;
466         let delay = ambiguous ? hints_ambiguous_auto_exit_delay : hints_auto_exit_delay;
467         if (delay > 0)
468             this.auto_exit_timer_ID = window.setTimeout(function () { hints_exit(window, s); },
469                                                         delay);
470     },
472     handle_input: function (m) {
473         this.clear_auto_exit_timer();
474         this.typed_number = "";
475         this.typed_string = m._input_text;
476         this.manager.current_hint_string = this.typed_string;
477         this.manager.current_hint_number = -1;
478         this.manager.update_valid_hints();
479         if (this.manager.valid_hints.length == 1)
480             this.handle_auto_exit(false /* unambiguous */);
481         else if (this.manager.valid_hints.length > 1)
482         this.handle_auto_exit(true /* ambiguous */);
483         this.update_minibuffer(m);
484     }
487 define_variable("hints_auto_exit_delay", 0,
488     "Delay (in milliseconds) after the most recent key stroke before a "+
489     "sole matching element is automatically selected.  When zero, "+
490     "automatic selection is disabled.  A value of 500 is a good "+
491     "starting point for an average-speed typist.");
493 define_variable("hints_ambiguous_auto_exit_delay", 0,
494     "Delay (in milliseconds) after the most recent key stroke before the "+
495     "first of an ambiguous match is automatically selected.  If this is "+
496     "set to 0, automatic selection in ambiguous matches is disabled.");
498 interactive("hints-handle-number", null,
499     function (I) {
500         let s = I.minibuffer.check_state(hints_minibuffer_state);
501         s.clear_auto_exit_timer();
502         var ch = String.fromCharCode(I.event.charCode);
503         var auto_exit_ambiguous = null; // null -> no auto exit; false -> not ambiguous; true -> ambiguous
504         /* TODO: implement number escaping */
505         // Number entered
506         s.typed_number += ch;
508         s.manager.select_hint(parseInt(s.typed_number));
509         var num = s.manager.current_hint_number;
510         if (num > 0 && num <= s.manager.valid_hints.length)
511             auto_exit_ambiguous = num * 10 > s.manager.valid_hints.length ? false : true;
512         else if (num == 0) {
513             if (!s.multiple) {
514                 hints_exit(I.window, s);
515                 return;
516             }
517             auto_exit_ambiguous = false;
518         }
519         if (auto_exit_ambiguous !== null)
520             s.handle_auto_exit(auto_exit_ambiguous);
521         s.update_minibuffer(I.minibuffer);
522     });
524 function hints_backspace (window, s) {
525     let m = window.minibuffer;
526     s.clear_auto_exit_timer();
527     if (s.typed_number.length > 0) {
528         s.typed_number = s.typed_number.substring(0, s.typed_number.length - 1);
529         var num = s.typed_number.length > 0 ? parseInt(s.typed_number) : 1;
530         s.manager.select_hint(num);
531     } else if (s.typed_string.length > 0) {
532         call_builtin_command(window, 'cmd_deleteCharBackward');
533         s.typed_string = m._input_text;
534         //m._set_selection();
535         s.manager.current_hint_string = s.typed_string;
536         s.manager.current_hint_number = -1;
537         s.manager.update_valid_hints();
538     }
539     s.update_minibuffer(m);
541 interactive("hints-backspace", null,
542     function (I) {
543         hints_backspace(I.window, I.minibuffer.check_state(hints_minibuffer_state));
544     });
546 function hints_next (window, s, count) {
547     s.clear_auto_exit_timer();
548     s.typed_number = "";
549     var cur = s.manager.current_hint_number - 1;
550     var vh = s.manager.valid_hints;
551     if (vh.length > 0) {
552         cur = (cur + count) % vh.length;
553         if (cur < 0)
554             cur += vh.length;
555         s.manager.select_hint(cur + 1);
556     }
557     s.update_minibuffer(window);
559 interactive("hints-next", null,
560     function (I) {
561         hints_next(I.window, I.minibuffer.check_state(hints_minibuffer_state), I.p);
562     });
564 interactive("hints-previous", null,
565     function (I) {
566         hints_next(I.window, I.minibuffer.check_state(hints_minibuffer_state), -I.p);
567     });
569 function hints_exit (window, s) {
570     var cur = s.manager.current_hint_number;
571     var elem = null;
572     if (cur > 0 && cur <= s.manager.valid_hints.length) {
573         elem = s.manager.valid_hints[cur - 1].elem;
574     } else if (cur == 0) {
575         elem = window.buffers.current.top_frame;
576     }
577     if (elem !== null) {
578         var c = s.continuation;
579         delete s.continuation;
580         window.minibuffer.pop_state();
581         if (c)
582             c(elem);
583     }
586 interactive("hints-exit", null,
587     function (I) {
588         hints_exit(I.window, I.minibuffer.check_state(hints_minibuffer_state));
589     });
591 interactive("hints-quote-next", null,
592     function (I) {
593         I.overlay_keymap = hint_quote_next_keymap;
594     },
595     $prefix);
598 define_keywords("$buffer");
599 minibuffer.prototype.read_hinted_element = function () {
600     keywords(arguments);
601     var buf = arguments.$buffer;
602     var s = new hints_minibuffer_state(this, (yield CONTINUATION), buf, forward_keywords(arguments));
603     this.push_state(s);
604     var result = yield SUSPEND;
605     yield co_return(result);
608 provide("hints");