Optionally display a URL panel when selecting from hints.
[conkeror.git] / modules / hints.js
blob9ea1454ec41e07daf08d4a4ba3c01d45a243837b
1 /**
2  * (C) Copyright 2007-2008 Jeremy Maitin-Shepard
3  *
4  * Portions of this file are derived from Vimperator,
5  * (C) Copyright 2006-2007 Martin Stubenschrott.
6  *
7  * Use, modification, and distribution are subject to the terms specified in the
8  * COPYING file.
9 **/
11 define_variable("active_img_hint_background_color", "#88FF00", "Color for the active image hint background.");
12 define_variable("img_hint_background_color", "yellow", "Color for inactive image hint backgrounds.");
13 define_variable("active_hint_background_color", "#88FF00", "Color for the active hint background.");
14 define_variable("hint_background_color", "yellow", "Color for the inactive hint.");
16 /**
17  * Register hints style sheet
18  */
19 const hints_stylesheet = "chrome://conkeror/content/hints.css";
20 register_user_stylesheet(hints_stylesheet);
22 /**
23  * buffer is a content_buffer
24  *
25  */
26 function hint_manager(window, xpath_expr, focused_frame, focused_element)
28     this.window = window;
29     this.hints = [];
30     this.valid_hints = [];
31     this.xpath_expr = xpath_expr;
32     this.focused_frame = focused_frame;
33     this.focused_element = focused_element;
34     this.last_selected_hint = null;
36     // Generate
37     this.generate_hints();
40 hint_manager.prototype = {
41     current_hint_string : "",
42     current_hint_number : -1,
44     /**
45      * Create an initially hidden hint span element absolutely
46      * positioned over each element that matches
47      * hint_xpath_expression.  This is done recursively for all frames
48      * and iframes.  Information about the resulting hints are also
49      * stored in the hints array.
50      */
51     generate_hints : function () {
52         var topwin = this.window;
53         var top_height = topwin.innerHeight;
54         var top_width = topwin.innerWidth;
55         var hints = this.hints;
56         var xpath_expr = this.xpath_expr;
57         var focused_frame_hint = null, focused_element_hint = null;
58         var focused_frame = this.focused_frame;
59         var focused_element = this.focused_element;
60         function helper(window, offsetX, offsetY) {
61             var win_height = window.height;
62             var win_width = window.width;
64             // Bounds
65             var minX = offsetX < 0 ? -offsetX : 0;
66             var minY = offsetY < 0 ? -offsetY : 0;
67             var maxX = offsetX + win_width > top_width ? top_width - offsetX : top_width;
68             var maxY = offsetY + win_height > top_height ? top_height - offsetY : top_height;
70             var scrollX = window.scrollX;
71             var scrollY = window.scrollY;
73             var doc = window.document;
74             var res = doc.evaluate(xpath_expr, doc, xpath_lookup_namespace,
75                                    Ci.nsIDOMXPathResult.UNORDERED_NODE_ITERATOR_TYPE,
76                                    null /* existing results */);
78             var base_node = doc.createElementNS(XHTML_NS, "span");
79             base_node.className = "__conkeror_hint";
81             var fragment = doc.createDocumentFragment();
82             var rect, elem, text, node, show_text;
83             while (true)
84             {
85                 try {
86                     elem = res.iterateNext();
87                     if (!elem)
88                         break;
89                 } catch (e) {
90                     break; // Iterator may have been invalidated by page load activity
91                 }
92                 rect = elem.getBoundingClientRect();
93                 if (!rect || rect.left > maxX || rect.right < minX || rect.top > maxY || rect.bottom < minY)
94                     continue;
95                 let style = topwin.getComputedStyle(elem, "");
96                 if (style.display == "none" || style.visibility == "hidden")
97                     continue;
98                 rect = elem.getClientRects()[0];
99                 if (!rect)
100                     continue;
101                 show_text = false;
102                 if (elem instanceof Ci.nsIDOMHTMLInputElement || elem instanceof Ci.nsIDOMHTMLTextAreaElement)
103                     text = elem.value;
104                 else if (elem instanceof Ci.nsIDOMHTMLSelectElement) {
105                     if (elem.selectedIndex >= 0)
106                         text = elem.item(elem.selectedIndex).text;
107                     else
108                         text = "";
109                 } else if (elem instanceof Ci.nsIDOMHTMLFrameElement) {
110                     text = elem.name ? elem.name : "";
111                 } else if (/^\s*$/.test(elem.textContent) &&
112                            elem.childNodes.length == 1 &&
113                            elem.childNodes.item(0) instanceof Ci.nsIDOMHTMLImageElement) {
114                     text = elem.childNodes.item(0).alt;
115                     show_text = true;
116                 } else
117                     text = elem.textContent;
118                 text = text.toLowerCase();
120                 node = base_node.cloneNode(true);
121                 node.style.left = (rect.left + scrollX) + "px";
122                 node.style.top = (rect.top + scrollY) + "px";
123                 fragment.appendChild(node);
125                 let hint = {text: text,
126                             elem: elem,
127                             hint: node,
128                             img_hint: null,
129                             visible: false,
130                             show_text: show_text};
131                 if (elem.style) {
132                     hint.saved_color = elem.style.color;
133                     hint.saved_bgcolor = elem.style.backgroundColor;
134                 }
135                 hints.push(hint);
137                 if (elem == focused_element)
138                     focused_element_hint = hint;
139                 else if ((elem instanceof Ci.nsIDOMHTMLFrameElement ||
140                           elem instanceof Ci.nsIDOMHTMLIFrameElement) &&
141                          elem.contentWindow == focused_frame)
142                     focused_frame_hint = hint;
143             }
144             doc.documentElement.appendChild(fragment);
146             /* Recurse into any IFRAME or FRAME elements */
147             var frametag = "frame";
148             while (true) {
149                 var frames = doc.getElementsByTagName(frametag);
150                 for (var i = 0; i < frames.length; ++i)
151                 {
152                     elem = frames[i];
153                     rect = elem.getBoundingClientRect();
154                     if (!rect || rect.left > maxX || rect.right < minX || rect.top > maxY || rect.bottom < minY)
155                         continue;
156                     helper(elem.contentWindow, offsetX + rect.left, offsetY + rect.top);
157                 }
158                 if (frametag == "frame") frametag = "iframe"; else break;
159             }
160         }
161         helper(topwin, 0, 0);
162         this.last_selected_hint = focused_element_hint || focused_frame_hint;
163     },
165     /* Updates valid_hints and also re-numbers and re-displays all hints. */
166     update_valid_hints : function () {
167         this.valid_hints = [];
168         var active_number = this.current_hint_number;
170         var tokens = this.current_hint_string.split(" ");
171         var rect, h, text, img_hint, doc, scrollX, scrollY;
172         var hints = this.hints;
174     outer:
175         for (var i = 0; i < hints.length; ++i)
176         {
177             h = hints[i];
178             text = h.text;
179             for (var j = 0; j < tokens.length; ++j)
180             {
181                 if (text.indexOf(tokens[j]) == -1)
182                 {
183                     if (h.visible)
184                     {
185                         h.visible = false;
186                         h.hint.style.display = "none";
187                         if (h.img_hint)
188                             h.img_hint.style.display = "none";
189                         if (h.saved_color != null) {
190                             h.elem.style.backgroundColor = h.saved_bgcolor;
191                             h.elem.style.color = h.saved_color;
192                         }
193                     }
194                     continue outer;
195                 }
196             }
198             var cur_number = this.valid_hints.length + 1;
199             h.visible = true;
201             if (h == this.last_selected_hint && active_number == -1)
202                 this.current_hint_number = active_number = cur_number;
204             var img_elem = null;
206             if (text.length == 0 && h.elem.firstChild &&
207                 h.elem.firstChild instanceof Ci.nsIDOMHTMLImageElement)
208                 img_elem = h.elem.firstChild;
209             else if (h.elem instanceof Ci.nsIDOMHTMLImageElement)
210                 img_elem = h.elem;
212             if (img_elem)
213             {
214                 if (!h.img_hint)
215                 {
216                     rect = img_elem.getBoundingClientRect();
217                     if (rect) {
218                         doc = h.elem.ownerDocument;
219                         scrollX = doc.defaultView.scrollX;
220                         scrollY = doc.defaultView.scrollY;
221                         img_hint = doc.createElementNS(XHTML_NS, "span");
222                         img_hint.className = "__conkeror_img_hint";
223                         img_hint.style.left = (rect.left + scrollX) + "px";
224                         img_hint.style.top = (rect.top + scrollY) + "px";
225                         img_hint.style.width = (rect.right - rect.left) + "px";
226                         img_hint.style.height = (rect.bottom - rect.top) + "px";
227                         h.img_hint = img_hint;
228                         doc.documentElement.appendChild(img_hint);
229                     } else
230                         img_elem = null;
231                 }
232                 if (img_elem) {
233                     var bgcolor = (active_number == cur_number) ?
234                         active_img_hint_background_color : img_hint_background_color;
235                     h.img_hint.style.backgroundColor = bgcolor;
236                     h.img_hint.style.display = "inline";
237                 }
238             }
240             if (!h.img_hint && h.elem.style)
241                 h.elem.style.backgroundColor = (active_number == cur_number) ?
242                     active_hint_background_color : hint_background_color;
244             if (h.elem.style)
245                 h.elem.style.color = "black";
247             var label = "" + cur_number;
248             if (h.elem instanceof Ci.nsIDOMHTMLFrameElement) {
249                 label +=  " " + text;
250             } else if (h.show_text && !/^\s*$/.test(text)) {
251                 let substrs = [[0,4]];
252                 for (j = 0; j < tokens.length; ++j)
253                 {
254                     let pos = text.indexOf(tokens[j]);
255                     if(pos == -1) continue;
256                     splice_range(substrs, pos, pos + tokens[j].length + 2);
257                 }
258                 label += " " + substrs.map(function(x) {
259                     return text.substring(x[0],Math.min(x[1], text.length));
260                 }).join("..") + "..";
261             }
262             h.hint.textContent = label;
263             h.hint.style.display = "inline";
264             this.valid_hints.push(h);
265         }
267         if (active_number == -1)
268             this.select_hint(1);
269     },
271     select_hint : function (index) {
272         var old_index = this.current_hint_number;
273         if (index == old_index)
274             return;
275         var vh = this.valid_hints;
276         if (old_index >= 1 && old_index <= vh.length)
277         {
278             var h = vh[old_index - 1];
279             if (h.img_hint)
280                 h.img_hint.style.backgroundColor = img_hint_background_color;
281             if (h.elem.style)
282                 h.elem.style.backgroundColor = hint_background_color;
283         }
284         this.current_hint_number = index;
285         if (index >= 1 && index <= vh.length)
286         {
287             var h = vh[index - 1];
288             if (h.img_hint)
289                 h.img_hint.style.backgroundColor = active_img_hint_background_color;
290             if (h.elem.style)
291                 h.elem.style.backgroundColor = active_hint_background_color;
292             this.last_selected_hint = h;
293         }
294     },
296     hide_hints : function () {
297         for (var i = 0; i < this.hints.length; ++i)
298         {
299             var h = this.hints[i];
300             if (h.visible) {
301                 h.visible = false;
302                 if (h.saved_color != null)
303                 {
304                     h.elem.style.color = h.saved_color;
305                     h.elem.style.backgroundColor = h.saved_bgcolor;
306                 }
307                 if (h.img_hint)
308                     h.img_hint.style.display = "none";
309                 h.hint.style.display = "none";
310             }
311         }
312     },
314     remove : function () {
315         for (var i = 0; i < this.hints.length; ++i)
316         {
317             var h = this.hints[i];
318             if (h.visible && h.saved_color != null) {
319                 h.elem.style.color = h.saved_color;
320                 h.elem.style.backgroundColor = h.saved_bgcolor;
321             }
322             if (h.img_hint)
323                 h.img_hint.parentNode.removeChild(h.img_hint);
324             h.hint.parentNode.removeChild(h.hint);
325         }
326         this.hints = [];
327         this.valid_hints = [];
328     }
331 /* Show panel with currently selected URL below the minibuffer. */
332 function hints_url_panel(hints, window) {
333     var g = new dom_generator(window.document, XUL_NS);
335     var p = g.element("hbox", "class", "panel url", "flex", "0");
336     g.element("label", p, "value", "URL:", "class", "url-panel-label");
337     var url_value = g.text("label", p, "class", "url-panel-value");
338     window.minibuffer.insert_before(p);
340     p.update = function() {
341         url_value.nodeValue = "";
342         if (hints.manager && hints.manager.last_selected_hint) {
343             var elem = hints.manager.last_selected_hint.elem;
344             if (elem instanceof Ci.nsIDOMHTMLAnchorElement)
345                 url_value.nodeValue = elem;
346         }
347     }
349     p.destroy = function() {
350         this.parentNode.removeChild(this);
351     };
353     return p;
356 define_variable("hints_display_url_panel", false, "When selecting a hint, the URL can be displayed in a panel above the minibuffer.  This is useful for confirming that the correct link is selected and that the URL is not evil.  This option is most useful when hints_auto_exit_delay is long or disabled.");
359  * keyword arguments:
361  * $prompt
362  * $callback
363  * $abort_callback
364  */
365 define_keywords("$keymap", "$auto", "$hint_xpath_expression", "$multiple");
366 function hints_minibuffer_state(continuation, buffer)
368     keywords(arguments, $keymap = hint_keymap, $auto);
369     basic_minibuffer_state.call(this, $prompt = arguments.$prompt);
370     if (hints_display_url_panel)
371         this.url_panel = hints_url_panel(this, buffer.window);
372     this.original_prompt = arguments.$prompt;
373     this.continuation = continuation;
374     this.keymap = arguments.$keymap;
375     this.auto_exit = arguments.$auto ? true : false;
376     this.xpath_expr = arguments.$hint_xpath_expression;
377     this.auto_exit_timer_ID = null;
378     this.multiple = arguments.$multiple;
379     this.focused_element = buffer.focused_element;
380     this.focused_frame = buffer.focused_frame;
382 hints_minibuffer_state.prototype = {
383     __proto__: basic_minibuffer_state.prototype,
384     manager : null,
385     typed_string : "",
386     typed_number : "",
387     load : function (window) {
388         if (!this.manager) {
389             var buf = window.buffers.current;
390             this.manager = new hint_manager(buf.top_frame, this.xpath_expr,
391                                             this.focused_frame, this.focused_element);
392         }
393         this.manager.update_valid_hints();
394         if (this.url_panel)
395             this.url_panel.update();
396     },
397     unload : function (window) {
398         if (this.auto_exit_timer_ID) {
399             window.clearTimeout(this.auto_exit_timer_ID);
400             this.auto_exit_timer_ID = null;
401         }
402         this.manager.hide_hints();
403     },
404     destroy : function () {
405         if (this.auto_exit_timer_ID) {
406             this.manager.window.clearTimeout(this.auto_exit_timer_ID);
407             this.auto_exit_timer_ID = null;
408         }
409         this.manager.remove();
410         if (this.url_panel)
411             this.url_panel.destroy();
412     },
413     update_minibuffer : function (m) {
414         if (this.typed_number.length > 0)
415             m.prompt = this.original_prompt + " #" + this.typed_number;
416         else
417             m.prompt = this.original_prompt;
418         if (this.url_panel)
419             this.url_panel.update();
420     },
422     handle_auto_exit : function (m, delay) {
423         let window = m.window;
424         var num = this.manager.current_hint_number;
425         if (this.auto_exit_timer_ID)
426             window.clearTimeout(this.auto_exit_timer_ID);
427         let s = this;
428         this.auto_exit_timer_ID = window.setTimeout(function() { hints_exit(window, s); },
429                                                     delay);
430     },
432     handle_input : function (m) {
433         m._set_selection();
435         this.typed_number = "";
436         this.typed_string = m._input_text;
437         this.manager.current_hint_string = this.typed_string;
438         this.manager.current_hint_number = -1;
439         this.manager.update_valid_hints();
440         if (this.auto_exit) {
441             if (this.manager.valid_hints.length == 1)
442                 this.handle_auto_exit(m, hints_auto_exit_delay);
443             else if (this.manager.valid_hints.length > 1
444                      && hints_ambiguous_auto_exit_delay > 0)
445                 this.handle_auto_exit(m, hints_ambiguous_auto_exit_delay);
446         }
447         this.update_minibuffer(m);
448     }
451 define_variable("hints_auto_exit_delay", 500, "Delay (in milliseconds) after the most recent key stroke before a sole matching element is automatically selected.  If this is set to 0, automatic selection is disabled.");
453 define_variable("hints_ambiguous_auto_exit_delay", 0, "Delay (in milliseconds) after the most recent key stroke before the first of an ambiguous match is automatically selected.  If this is set to 0, automatic selection in ambiguous matches is disabled.");
455 interactive("hints-handle-number", null, function (I) {
456     let s = I.minibuffer.check_state(hints_minibuffer_state);
457     var ch = String.fromCharCode(I.event.charCode);
458     var auto_exit = false;
459     /* TODO: implement number escaping */
460     // Number entered
461     s.typed_number += ch;
463     s.manager.select_hint(parseInt(s.typed_number));
464     var num = s.manager.current_hint_number;
465     if (s.auto_exit) {
466         if (num > 0 && num <= s.manager.valid_hints.length) {
467             if (num * 10 > s.manager.valid_hints.length)
468                 auto_exit = hints_auto_exit_delay;
469             else if (hints_ambiguous_auto_exit_delay > 0)
470                 auto_exit = hints_ambiguous_auto_exit_delay;
471         }
472         if (num == 0) {
473             if (!s.multiple) {
474                 hints_exit(I.window, s);
475                 return;
476             }
477             auto_exit = hints_auto_exit_delay;
478         }
479     }
480     if (auto_exit)
481         s.handle_auto_exit(I.minibuffer, auto_exit);
482     s.update_minibuffer(I.minibuffer);
485 function hints_backspace(window, s) {
486     let m = window.minibuffer;
487     if (s.auto_exit_timer_ID) {
488         window.clearTimeout(s.auto_exit_timer_ID);
489         s.auto_exit_timer_ID = null;
490     }
491     if (s.typed_number.length > 0) {
492         s.typed_number = s.typed_number.substring(0, s.typed_number.length - 1);
493         var num = s.typed_number.length > 0 ? parseInt(s.typed_number) : 1;
494         s.manager.select_hint(num);
495     } else if (s.typed_string.length > 0) {
496         s.typed_string = s.typed_string.substring(0, s.typed_string.length - 1);
497         m._input_text = s.typed_string;
498         m._set_selection();
499         s.manager.current_hint_string = s.typed_string;
500         s.manager.current_hint_number = -1;
501         s.manager.update_valid_hints();
502     }
503     s.update_minibuffer(m);
505 interactive("hints-backspace", null, function (I) {
506     hints_backspace(I.window, I.minibuffer.check_state(hints_minibuffer_state));
509 function hints_next(window, s, count) {
510     if (s.auto_exit_timer_ID) {
511         window.clearTimeout(s.auto_exit_timer_ID);
512         s.auto_exit_timer_ID = null;
513     }
514     s.typed_number = "";
515     var cur = s.manager.current_hint_number - 1;
516     var vh = s.manager.valid_hints;
517     if (vh.length > 0) {
518         cur = (cur + count) % vh.length;
519         if (cur < 0)
520             cur += vh.length;
521         s.manager.select_hint(cur + 1);
522     }
523     s.update_minibuffer(window);
525 interactive("hints-next", null, function (I) {
526     hints_next(I.window, I.minibuffer.check_state(hints_minibuffer_state), I.p);
529 interactive("hints-previous", null, function (I) {
530     hints_next(I.window, I.minibuffer.check_state(hints_minibuffer_state), -I.p);
533 function hints_exit(window, s)
535     if (s.auto_exit_timer_ID) {
536         window.clearTimeout(s.auto_exit_timer_ID);
537         s.auto_exit_timer_ID = null;
538     }
539     var cur = s.manager.current_hint_number;
540     var elem = null;
541     if (cur > 0 && cur <= s.manager.valid_hints.length)
542         elem = s.manager.valid_hints[cur - 1].elem;
543     else if (cur == 0)
544         elem = window.buffers.current.top_frame;
545     if (elem) {
546         var c = s.continuation;
547         delete s.continuation;
548         window.minibuffer.pop_state();
549         if (c)
550             c(elem);
551     }
554 interactive("hints-exit", null, function (I) {
555     hints_exit(I.window, I.minibuffer.check_state(hints_minibuffer_state));
559 /* FIXME: figure out why this needs to have a bunch of duplication */
560 define_variable(
561     "hints_xpath_expressions",
562     {
563         images: {def: "//img | //xhtml:img"},
564         frames: {def: "//iframe | //frame | //xhtml:iframe | //xhtml:frame"},
565         links: {def:
566                 "//*[@onclick or @onmouseover or @onmousedown or @onmouseup or @oncommand or @class='lk' or @class='s'] | " +
567                 "//input[not(@type='hidden')] | //a | //area | //iframe | //textarea | //button | //select | " +
568                 "//xhtml:*[@onclick or @onmouseover or @onmousedown or @onmouseup or @oncommand or @class='lk' or @class='s'] | " +
569                 "//xhtml:input[not(@type='hidden')] | //xhtml:a | //xhtml:area | //xhtml:iframe | //xhtml:textarea | " +
570                 "//xhtml:button | //xhtml:select"},
571         mathml: {def: "//m:math"}
572     },
573     "XPath expressions for each object class.");
575 minibuffer_auto_complete_preferences["media"] = true;
577 define_keywords("$buffer");
578 minibuffer.prototype.read_hinted_element = function () {
579     keywords(arguments);
580     var buf = arguments.$buffer;
581     var s = new hints_minibuffer_state((yield CONTINUATION), buf, forward_keywords(arguments));
582     this.push_state(s);
583     var result = yield SUSPEND;
584     yield co_return(result);