hints_manager: don't assume correctly written html for area elements
[conkeror.git] / modules / hints.js
blob39d4ac90a710148e6983b2af1f392d0740059c7d
1 /**
2  * (C) Copyright 2007-2008 Jeremy Maitin-Shepard
3  * (C) Copyright 2009 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 define_variable("active_img_hint_background_color", "#88FF00",
13     "Color for the active image hint background.");
15 define_variable("img_hint_background_color", "yellow",
16     "Color for inactive image hint backgrounds.");
18 define_variable("active_hint_background_color", "#88FF00",
19     "Color for the active hint background.");
21 define_variable("hint_background_color", "yellow",
22     "Color for the inactive hint.");
25 /**
26  * Register hints style sheet
27  */
28 const hints_stylesheet = "chrome://conkeror-gui/content/hints.css";
29 register_user_stylesheet(hints_stylesheet);
31 /**
32  *   In the hints interaction, a node can be selected either by typing
33  * the number of its associated hint, or by typing substrings of the
34  * text content of the node.  In the case of selecting by text
35  * content, multiple substrings can be given by separating them with
36  * spaces.
37  */
38 function hint_manager (window, xpath_expr, focused_frame, focused_element) {
39     this.window = window;
40     this.hints = [];
41     this.valid_hints = [];
42     this.xpath_expr = xpath_expr;
43     this.focused_frame = focused_frame;
44     this.focused_element = focused_element;
45     this.last_selected_hint = null;
47     // Generate
48     this.generate_hints();
51 hint_manager.prototype = {
52     current_hint_string: "",
53     current_hint_number: -1,
55     /**
56      * Create an initially hidden hint span element absolutely
57      * positioned over each element that matches
58      * hint_xpath_expression.  This is done recursively for all frames
59      * and iframes.  Information about the resulting hints are also
60      * stored in the hints array.
61      */
62     generate_hints: function () {
63         var topwin = this.window;
64         var top_height = topwin.innerHeight;
65         var top_width = topwin.innerWidth;
66         var hints = this.hints;
67         var xpath_expr = this.xpath_expr;
68         var focused_frame_hint = null, focused_element_hint = null;
69         var focused_frame = this.focused_frame;
70         var focused_element = this.focused_element;
72         function helper (window, offsetX, offsetY) {
73             var win_height = window.height;
74             var win_width = window.width;
76             // Bounds
77             var minX = offsetX < 0 ? -offsetX : 0;
78             var minY = offsetY < 0 ? -offsetY : 0;
79             var maxX = offsetX + win_width > top_width ? top_width - offsetX : top_width;
80             var maxY = offsetY + win_height > top_height ? top_height - offsetY : top_height;
82             var scrollX = window.scrollX;
83             var scrollY = window.scrollY;
85             var doc = window.document;
86             var res = doc.evaluate(xpath_expr, doc, xpath_lookup_namespace,
87                                    Ci.nsIDOMXPathResult.UNORDERED_NODE_ITERATOR_TYPE,
88                                    null /* existing results */);
90             var base_node = doc.createElementNS(XHTML_NS, "span");
91             base_node.className = "__conkeror_hint";
93             var fragment = doc.createDocumentFragment();
94             var rect, elem, text, node, show_text;
95             while (true) {
96                 try {
97                     elem = res.iterateNext();
98                     if (!elem)
99                         break;
100                 } catch (e) {
101                     break; // Iterator may have been invalidated by page load activity
102                 }
103                 rect = elem.getBoundingClientRect();
104                 if (elem instanceof Ci.nsIDOMHTMLAreaElement) {
105                     rect = { top: rect.top,
106                              left: rect.left,
107                              bottom: rect.bottom,
108                              right: rect.right };
109                     var coords = elem.getAttribute("coords")
110                         .match(/^(\d+)\D+(\d+)/);
111                     if (coords.length == 3) {
112                         rect.left += parseInt(coords[1]);
113                         rect.top += parseInt(coords[2]);
114                     }
115                 }
116                 if (!rect || rect.left > maxX || rect.right < minX || rect.top > maxY || rect.bottom < minY)
117                     continue;
118                 let style = topwin.getComputedStyle(elem, "");
119                 if (style.display == "none" || style.visibility == "hidden")
120                     continue;
121                 if (! (elem instanceof Ci.nsIDOMHTMLAreaElement))
122                     rect = elem.getClientRects()[0];
123                 if (!rect)
124                     continue;
125                 show_text = false;
126                 if (elem instanceof Ci.nsIDOMHTMLInputElement || elem instanceof Ci.nsIDOMHTMLTextAreaElement)
127                     text = elem.value;
128                 else if (elem instanceof Ci.nsIDOMHTMLSelectElement) {
129                     if (elem.selectedIndex >= 0)
130                         text = elem.item(elem.selectedIndex).text;
131                     else
132                         text = "";
133                 } else if (elem instanceof Ci.nsIDOMHTMLFrameElement) {
134                     text = elem.name ? elem.name : "";
135                 } else if (/^\s*$/.test(elem.textContent) &&
136                            elem.childNodes.length == 1 &&
137                            elem.childNodes.item(0) instanceof Ci.nsIDOMHTMLImageElement) {
138                     text = elem.childNodes.item(0).alt;
139                     show_text = true;
140                 } else
141                     text = elem.textContent;
142                 text = text.toLowerCase();
144                 node = base_node.cloneNode(true);
145                 node.style.left = (rect.left + scrollX) + "px";
146                 node.style.top = (rect.top + scrollY) + "px";
147                 fragment.appendChild(node);
149                 let hint = { text: text,
150                              elem: elem,
151                              hint: node,
152                              img_hint: null,
153                              visible: false,
154                              show_text: show_text };
155                 if (elem.style) {
156                     hint.saved_color = elem.style.color;
157                     hint.saved_bgcolor = elem.style.backgroundColor;
158                 }
159                 hints.push(hint);
161                 if (elem == focused_element)
162                     focused_element_hint = hint;
163                 else if ((elem instanceof Ci.nsIDOMHTMLFrameElement ||
164                           elem instanceof Ci.nsIDOMHTMLIFrameElement) &&
165                          elem.contentWindow == focused_frame)
166                     focused_frame_hint = hint;
167             }
168             doc.documentElement.appendChild(fragment);
170             /* Recurse into any IFRAME or FRAME elements */
171             var frametag = "frame";
172             while (true) {
173                 var frames = doc.getElementsByTagName(frametag);
174                 for (var i = 0; i < frames.length; ++i) {
175                     elem = frames[i];
176                     rect = elem.getBoundingClientRect();
177                     if (!rect || rect.left > maxX || rect.right < minX || rect.top > maxY || rect.bottom < minY)
178                         continue;
179                     helper(elem.contentWindow, offsetX + rect.left, offsetY + rect.top);
180                 }
181                 if (frametag == "frame") frametag = "iframe"; else break;
182             }
183         }
184         helper(topwin, 0, 0);
185         this.last_selected_hint = focused_element_hint || focused_frame_hint;
186     },
188     /* Updates valid_hints and also re-numbers and re-displays all hints. */
189     update_valid_hints: function () {
190         this.valid_hints = [];
191         var active_number = this.current_hint_number;
193         var tokens = this.current_hint_string.split(" ");
194         var rect, h, text, img_hint, doc, scrollX, scrollY;
195         var hints = this.hints;
197     outer:
198         for (var i = 0; i < hints.length; ++i) {
199             h = hints[i];
200             text = h.text;
201             for (var j = 0; j < tokens.length; ++j) {
202                 if (text.indexOf(tokens[j]) == -1) {
203                     if (h.visible) {
204                         h.visible = false;
205                         h.hint.style.display = "none";
206                         if (h.img_hint)
207                             h.img_hint.style.display = "none";
208                         if (h.saved_color != null) {
209                             h.elem.style.backgroundColor = h.saved_bgcolor;
210                             h.elem.style.color = h.saved_color;
211                         }
212                     }
213                     continue outer;
214                 }
215             }
217             var cur_number = this.valid_hints.length + 1;
218             h.visible = true;
220             if (h == this.last_selected_hint && active_number == -1)
221                 this.current_hint_number = active_number = cur_number;
223             var img_elem = null;
225             if (text.length == 0 && h.elem.firstChild &&
226                 h.elem.firstChild instanceof Ci.nsIDOMHTMLImageElement)
227                 img_elem = h.elem.firstChild;
228             else if (h.elem instanceof Ci.nsIDOMHTMLImageElement)
229                 img_elem = h.elem;
231             if (img_elem) {
232                 if (!h.img_hint) {
233                     rect = img_elem.getBoundingClientRect();
234                     if (rect) {
235                         doc = h.elem.ownerDocument;
236                         scrollX = doc.defaultView.scrollX;
237                         scrollY = doc.defaultView.scrollY;
238                         img_hint = doc.createElementNS(XHTML_NS, "span");
239                         img_hint.className = "__conkeror_img_hint";
240                         img_hint.style.left = (rect.left + scrollX) + "px";
241                         img_hint.style.top = (rect.top + scrollY) + "px";
242                         img_hint.style.width = (rect.right - rect.left) + "px";
243                         img_hint.style.height = (rect.bottom - rect.top) + "px";
244                         h.img_hint = img_hint;
245                         doc.documentElement.appendChild(img_hint);
246                     } else
247                         img_elem = null;
248                 }
249                 if (img_elem) {
250                     var bgcolor = (active_number == cur_number) ?
251                         active_img_hint_background_color : img_hint_background_color;
252                     h.img_hint.style.backgroundColor = bgcolor;
253                     h.img_hint.style.display = "inline";
254                 }
255             }
257             if (!h.img_hint && h.elem.style)
258                 h.elem.style.backgroundColor = (active_number == cur_number) ?
259                     active_hint_background_color : hint_background_color;
261             if (h.elem.style)
262                 h.elem.style.color = "black";
264             var label = "" + cur_number;
265             if (h.elem instanceof Ci.nsIDOMHTMLFrameElement) {
266                 label +=  " " + text;
267             } else if (h.show_text && !/^\s*$/.test(text)) {
268                 let substrs = [[0,4]];
269                 for (j = 0; j < tokens.length; ++j) {
270                     let pos = text.indexOf(tokens[j]);
271                     if(pos == -1) continue;
272                     splice_range(substrs, pos, pos + tokens[j].length + 2);
273                 }
274                 label += " " + substrs.map(function (x) {
275                     return text.substring(x[0],Math.min(x[1], text.length));
276                 }).join("..") + "..";
277             }
278             h.hint.textContent = label;
279             h.hint.style.display = "inline";
280             this.valid_hints.push(h);
281         }
283         if (active_number == -1)
284             this.select_hint(1);
285     },
287     select_hint: function (index) {
288         var old_index = this.current_hint_number;
289         if (index == old_index)
290             return;
291         var vh = this.valid_hints;
292         if (old_index >= 1 && old_index <= vh.length) {
293             var h = vh[old_index - 1];
294             if (h.img_hint)
295                 h.img_hint.style.backgroundColor = img_hint_background_color;
296             if (h.elem.style)
297                 h.elem.style.backgroundColor = hint_background_color;
298         }
299         this.current_hint_number = index;
300         if (index >= 1 && index <= vh.length) {
301             var h = vh[index - 1];
302             if (h.img_hint)
303                 h.img_hint.style.backgroundColor = active_img_hint_background_color;
304             if (h.elem.style)
305                 h.elem.style.backgroundColor = active_hint_background_color;
306             this.last_selected_hint = h;
307         }
308     },
310     hide_hints: function () {
311         for (var i = 0; i < this.hints.length; ++i) {
312             var h = this.hints[i];
313             if (h.visible) {
314                 h.visible = false;
315                 if (h.saved_color != null) {
316                     h.elem.style.color = h.saved_color;
317                     h.elem.style.backgroundColor = h.saved_bgcolor;
318                 }
319                 if (h.img_hint)
320                     h.img_hint.style.display = "none";
321                 h.hint.style.display = "none";
322             }
323         }
324     },
326     remove: function () {
327         for (var i = 0; i < this.hints.length; ++i) {
328             var h = this.hints[i];
329             if (h.visible && h.saved_color != null) {
330                 h.elem.style.color = h.saved_color;
331                 h.elem.style.backgroundColor = h.saved_bgcolor;
332             }
333             if (h.img_hint)
334                 h.img_hint.parentNode.removeChild(h.img_hint);
335             h.hint.parentNode.removeChild(h.hint);
336         }
337         this.hints = [];
338         this.valid_hints = [];
339     }
342 /* Show panel with currently selected URL. */
343 function hints_url_panel (hints, window) {
344     var g = new dom_generator(window.document, XUL_NS);
346     var p = g.element("hbox", "class", "panel url", "flex", "0");
347     g.element("label", p, "value", "URL:", "class", "url-panel-label");
348     var url_value = g.element("label", p, "class", "url-panel-value",
349                               "crop", "end", "flex", "1");
350     window.minibuffer.insert_before(p);
352     p.update = function () {
353         url_value.value = "";
354         if (hints.manager && hints.manager.last_selected_hint) {
355             var spec;
356             try {
357                 spec = load_spec(hints.manager.last_selected_hint.elem);
358             } catch (e) {}
359             if (spec) {
360                 var uri = load_spec_uri_string(spec);
361                 if (uri) url_value.value = uri;
362             }
363         }
364     };
366     p.destroy = function () {
367         this.parentNode.removeChild(this);
368     };
370     return p;
373 define_variable("hints_display_url_panel", false,
374     "When selecting a hint, the URL can be displayed in a panel above "+
375     "the minibuffer.  This is useful for confirming that the correct "+
376     "link is selected and that the URL is not evil.  This option is "+
377     "most useful when hints_auto_exit_delay is long or disabled.");
380  * keyword arguments:
382  * $prompt
383  * $callback
384  * $abort_callback
385  */
386 define_keywords("$keymap", "$auto", "$hint_xpath_expression", "$multiple");
387 function hints_minibuffer_state (window, continuation, buffer) {
388     keywords(arguments, $keymap = hint_keymap, $auto);
389     basic_minibuffer_state.call(this, window, $prompt = arguments.$prompt);
390     if (hints_display_url_panel)
391         this.url_panel = hints_url_panel(this, buffer.window);
392     this.original_prompt = arguments.$prompt;
393     this.continuation = continuation;
394     this.keymap = arguments.$keymap;
395     this.auto_exit = arguments.$auto ? true : false;
396     this.xpath_expr = arguments.$hint_xpath_expression;
397     this.auto_exit_timer_ID = null;
398     this.multiple = arguments.$multiple;
399     this.focused_element = buffer.focused_element;
400     this.focused_frame = buffer.focused_frame;
402 hints_minibuffer_state.prototype = {
403     __proto__: basic_minibuffer_state.prototype,
404     manager: null,
405     typed_string: "",
406     typed_number: "",
407     load: function (window) {
408         basic_minibuffer_state.prototype.load.call(this, window);
409         if (!this.manager) {
410             var buf = window.buffers.current;
411             this.manager = new hint_manager(buf.top_frame, this.xpath_expr,
412                                             this.focused_frame, this.focused_element);
413         }
414         this.manager.update_valid_hints();
415         this.window = window;
416         if (this.url_panel)
417             this.url_panel.update();
418     },
419     clear_auto_exit_timer: function () {
420         if (this.auto_exit_timer_ID != null) {
421             this.window.clearTimeout(this.auto_exit_timer_ID);
422             this.auto_exit_timer_ID = null;
423         }
424     },
425     unload: function (window) {
426         this.clear_auto_exit_timer();
427         this.manager.hide_hints();
428         delete this.window;
429         basic_minibuffer_state.prototype.unload.call(this, window);
430     },
431     destroy: function (window) {
432         this.clear_auto_exit_timer();
433         this.manager.remove();
434         if (this.url_panel)
435             this.url_panel.destroy();
436         basic_minibuffer_state.prototype.destroy.call(this, window);
437     },
438     update_minibuffer: function (m) {
439         if (this.typed_number.length > 0)
440             m.prompt = this.original_prompt + " #" + this.typed_number;
441         else
442             m.prompt = this.original_prompt;
443         if (this.url_panel)
444             this.url_panel.update();
445     },
447     handle_auto_exit: function (m, ambiguous) {
448         let window = m.window;
449         var num = this.manager.current_hint_number;
450         if (!this.auto_exit)
451             return;
452         let s = this;
453         let delay = ambiguous ? hints_ambiguous_auto_exit_delay : hints_auto_exit_delay;
454         if (delay > 0)
455             this.auto_exit_timer_ID = window.setTimeout(function () { hints_exit(window, s); },
456                                                         delay);
457     },
459     ran_minibuffer_command: function (m) {
460         this.handle_input(m);
461     },
463     handle_input: function (m) {
464         m._set_selection();
465         this.clear_auto_exit_timer();
466         this.typed_number = "";
467         this.typed_string = m._input_text;
468         this.manager.current_hint_string = this.typed_string;
469         this.manager.current_hint_number = -1;
470         this.manager.update_valid_hints();
471         if (this.manager.valid_hints.length == 1)
472             this.handle_auto_exit(m, false /* unambiguous */);
473         else if (this.manager.valid_hints.length > 1)
474         this.handle_auto_exit(m, true /* ambiguous */);
475         this.update_minibuffer(m);
476     }
479 define_variable("hints_auto_exit_delay", 500,
480     "Delay (in milliseconds) after the most recent key stroke before a "+
481     "sole matching element is automatically selected.  If this is set to "+
482     "0, automatic selection is disabled.");
484 define_variable("hints_ambiguous_auto_exit_delay", 0,
485     "Delay (in milliseconds) after the most recent key stroke before the "+
486     "first of an ambiguous match is automatically selected.  If this is "+
487     "set to 0, automatic selection in ambiguous matches is disabled.");
489 interactive("hints-handle-number", null,
490     function (I) {
491         let s = I.minibuffer.check_state(hints_minibuffer_state);
492         s.clear_auto_exit_timer();
493         var ch = String.fromCharCode(I.event.charCode);
494         var auto_exit_ambiguous = null; // null -> no auto exit; false -> not ambiguous; true -> ambiguous
495         /* TODO: implement number escaping */
496         // Number entered
497         s.typed_number += ch;
499         s.manager.select_hint(parseInt(s.typed_number));
500         var num = s.manager.current_hint_number;
501         if (num > 0 && num <= s.manager.valid_hints.length)
502             auto_exit_ambiguous = num * 10 > s.manager.valid_hints.length ? false : true;
503         else if (num == 0) {
504             if (!s.multiple) {
505                 hints_exit(I.window, s);
506                 return;
507             }
508             auto_exit_ambiguous = false;
509         }
510         if (auto_exit_ambiguous !== null)
511             s.handle_auto_exit(I.minibuffer, auto_exit_ambiguous);
512         s.update_minibuffer(I.minibuffer);
513     });
515 function hints_backspace (window, s) {
516     let m = window.minibuffer;
517     s.clear_auto_exit_timer();
518     if (s.typed_number.length > 0) {
519         s.typed_number = s.typed_number.substring(0, s.typed_number.length - 1);
520         var num = s.typed_number.length > 0 ? parseInt(s.typed_number) : 1;
521         s.manager.select_hint(num);
522     } else if (s.typed_string.length > 0) {
523         s.typed_string = s.typed_string.substring(0, s.typed_string.length - 1);
524         m._input_text = s.typed_string;
525         m._set_selection();
526         s.manager.current_hint_string = s.typed_string;
527         s.manager.current_hint_number = -1;
528         s.manager.update_valid_hints();
529     }
530     s.update_minibuffer(m);
532 interactive("hints-backspace", null,
533     function (I) {
534         hints_backspace(I.window, I.minibuffer.check_state(hints_minibuffer_state));
535     });
537 function hints_next (window, s, count) {
538     s.clear_auto_exit_timer();
539     s.typed_number = "";
540     var cur = s.manager.current_hint_number - 1;
541     var vh = s.manager.valid_hints;
542     if (vh.length > 0) {
543         cur = (cur + count) % vh.length;
544         if (cur < 0)
545             cur += vh.length;
546         s.manager.select_hint(cur + 1);
547     }
548     s.update_minibuffer(window);
550 interactive("hints-next", null,
551     function (I) {
552         hints_next(I.window, I.minibuffer.check_state(hints_minibuffer_state), I.p);
553     });
555 interactive("hints-previous", null,
556     function (I) {
557         hints_next(I.window, I.minibuffer.check_state(hints_minibuffer_state), -I.p);
558     });
560 function hints_exit (window, s) {
561     var cur = s.manager.current_hint_number;
562     var elem = null;
563     if (cur > 0 && cur <= s.manager.valid_hints.length) {
564         elem = s.manager.valid_hints[cur - 1].elem;
565     } else if (cur == 0) {
566         elem = window.buffers.current.top_frame;
567     }
568     if (elem !== null) {
569         var c = s.continuation;
570         delete s.continuation;
571         window.minibuffer.pop_state();
572         if (c)
573             c(elem);
574     }
577 interactive("hints-exit", null,
578     function (I) {
579         hints_exit(I.window, I.minibuffer.check_state(hints_minibuffer_state));
580     });
583 define_keywords("$buffer");
584 minibuffer.prototype.read_hinted_element = function () {
585     keywords(arguments);
586     var buf = arguments.$buffer;
587     var s = new hints_minibuffer_state(this.window, (yield CONTINUATION), buf, forward_keywords(arguments));
588     this.push_state(s);
589     var result = yield SUSPEND;
590     yield co_return(result);