PageRenderTime 83ms CodeModel.GetById 14ms app.highlight 62ms RepoModel.GetById 1ms app.codeStats 0ms

/type-ahead.js

http://chrome-type-ahead.googlecode.com/
JavaScript | 636 lines | 610 code | 5 blank | 21 comment | 0 complexity | cf191877fcda828d15733a38aeebca8f MD5 | raw file
  1/*
  2 type-ahead-find: find text or links as you write. 
  3 
  4 This script is free software: you can redistribute it and/or modify
  5 it under the terms of the GNU General Public License as published by
  6 the Free Software Foundation, either version 3 of the License, or
  7 (at your option) any later version.
  8
  9 This script is distributed in the hope that it will be useful,
 10 but WITHOUT ANY WARRANTY; without even the implied warranty of
 11 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 12 GNU General Public License for more details.
 13
 14 You should have received a copy of the GNU General Public License
 15 along with this script.  If not, see <http://www.gnu.org/licenses/>.
 16 
 17 Website: http://code.google.com/p/chrome-type-ahead/
 18 Author: Arnau Sanchez <tokland@gmail.com> (2009) 
 19*/ 
 20
 21/* Styles and addStyle borrowed from nice-alert.js project */
 22
 23var styles = '\
 24  #type-ahead-box {\
 25    position: fixed;\
 26    top: 0;\
 27    right: 0;\
 28    margin: 0;\
 29    text-align: left;\
 30    z-index: 2147483647;\
 31    color: #000;\
 32    border-bottom: 1px solid #ccc;\
 33    border-bottom: 1px solid rgba(0,0,0,0.3);\
 34    padding: 4px 8px;\
 35    opacity: 0.9;\
 36    float: right;\
 37    clear: both;\
 38    overflow: hidden;\
 39    font-size: 18px;\
 40    font-family: Arial, Verdana, Georgia, Serif;\
 41    white-space: pre-wrap;\
 42    min-width: 60px;\
 43    outline: 0;\
 44    -webkit-box-shadow: 0px 2px 8px rgba(0,0,0,0.2);\
 45    -moz-box-shadow: 0px 2px 8px rgba(0,0,0,0.3);\
 46  }\
 47  \
 48  #type-ahead-box small {\
 49    letter-spacing: -0.12em;\
 50    color: #444;\
 51  }'
 52
 53/* Generic functions */
 54
 55function max(a, b) {
 56  return ((a > b) ? a : b); 
 57}
 58
 59function escape_regexp(s, ignore) {        
 60  var special = ["\\", "?", ".", "+", "(", ")", "{", "}", "[", "]", "$", "^", "*"];
 61  special.forEach(function(re) {
 62    if (!ignore || ignore.indexOf(re) < 0) 
 63      s = s.replace(new RegExp("\\" + re, "g"), "\\" + re);
 64  });
 65  return s;
 66}
 67
 68function get_current_zoom(doc) {
 69  var s = doc.body.parentElement.style.zoom;
 70  var zoom;
 71  if (s.match('%$'))
 72    zoom = parseFloat(s.slice(0, s.length - 1)) / 100;
 73  else if (s)
 74    zoom = parseFloat(s.replace(',', '.'));
 75  else
 76    zoom = 1;
 77  return zoom;
 78}
 79
 80function addStyle(css) {
 81  var head = document.getElementsByTagName('head')[0];
 82  if (head) {
 83    var style = document.createElement("style");
 84    style.type = "text/css";
 85    style.appendChild(document.createTextNode(css));
 86    head.appendChild(style);
 87  }
 88}
 89
 90function stopEvent(ev) {
 91  ev.preventDefault();
 92  ev.stopPropagation();
 93}
 94    
 95function up(element, tagName) {
 96  var upTagName = tagName.toUpperCase();
 97  while (element && (!element.tagName || 
 98                     element.tagName.toUpperCase() != upTagName)) {
 99    element = element.parentNode;
100  }
101  return element;
102}
103
104function upMatch(element, matchFunction) {
105  while (element) {
106    var res = matchFunction(element);
107    if (res == null)
108      return null;
109    else if (res)
110      return element;
111    element = element.parentNode;
112  }
113  return element;
114}
115
116function isVisible(element) {
117  while (element) {    
118    style = window.getComputedStyle(element);
119    if (style && (style.getPropertyValue('display') == 'none' ||
120                  style.getPropertyValue('visibility') == 'hidden'))
121      return false;
122    element = element.parentNode;
123  }
124  return true;
125}
126
127function getRootNodes() {  
128  var rootNodes = new Array();
129  var frames = document.getElementsByTagName('frame');
130  for (var i = 0; i < frames.length; i++)
131    rootNodes.push(frames[i]);
132  return rootNodes;
133}
134
135function clearRanges() {
136  var rootNodes = [window].concat(getRootNodes());
137  for (var i = 0; i < rootNodes.length; i++) {
138    var w = rootNodes[i].contentDocument || rootNodes[i];
139    if (w && w.getSelection) { 
140      var selection = w.getSelection();    
141      selection.removeAllRanges();
142    }
143  }
144}
145
146function getActiveElement(doc) {
147  return doc.activeElement || doc._tafActiveElement;
148}
149
150function getSelectedAnchor(doc) {
151  var rootNodes = [document].concat(getRootNodes());
152  for (var i = 0; i < rootNodes.length; i++) {
153    var doc = rootNodes[i].contentDocument || rootNodes[i];
154    var element = getActiveElement(doc);  
155    if (element && element.tagName == "A")
156      return(element);
157  }
158}
159
160function isInputElementActive(doc) {
161  var element = getActiveElement(doc);
162  if (!element)
163    return;
164  var name = element.tagName.toLowerCase();
165  if (["input", "select", "textarea", "object", "embed"].indexOf(name) >= 0)
166    return true;
167  return (upMatch(element, function(el) {
168      if (!el.getAttribute || el.getAttribute('contenteditable') == 'false')
169        return null;
170      return el.getAttribute('contenteditable'); 
171    }))
172}
173
174function selectOnchange(select) {
175  if (select) {
176    select.focus(); 
177    if (select.onchange) 
178      select.onchange();
179    return true;
180  }
181}
182
183function setAlternativeActiveDocument(doc) {
184  function dom_trackActiveElement(evt) {
185    if (evt && evt.target) { 
186      doc._tafActiveElement = (evt.target == doc) ? null : evt.target;
187    }
188  }
189
190  function dom_trackActiveElementLost(evt) { 
191    doc._tafActiveElement = null;
192  }
193
194  if (doc._tafActiveElement == undefined && !doc.activeElement) {
195    doc._tafActiveElement = null;
196    doc.addEventListener("focus", dom_trackActiveElement, true);
197    doc.addEventListener("blur", dom_trackActiveElementLost, true);
198  }
199}
200
201/* Type-ahead specific functions */
202
203function clearSearchBox() {
204  var box = document.getElementById('type-ahead-box');
205  if (box) {
206    box.style.display = 'none';
207  }
208}
209
210function isSearchBoxVisible() {
211  var box = document.getElementById('type-ahead-box');
212  return (box && box.style.display != 'none');
213}
214
215
216function showSearchBox(search, clear_function) {
217  var colors = {
218    text: {ok: search.options.color_text, ko: search.options.color_notfound},
219    links: {ok: search.options.color_link, ko: search.options.color_notfound},
220  }
221  var box = document.getElementById('type-ahead-box');
222  if (!box) { 
223    box = document.createElement('TABOX');
224    box.id = 'type-ahead-box';
225    document.documentElement.appendChild(box);
226    addStyle(styles);
227  }
228  box.style.display = 'block';
229  if (search.mode) {
230    var color = colors[search.mode][(search.total < 1 && search.text) ? 'ko' : 'ok'] 
231    box.style['background-color'] = color;
232  }
233  box.innerHTML = search.text ? 
234    (search.text + ' <small>(' + search.nmatch + ' of ' + search.total + ')</small>') : '&nbsp;';
235  box.style['top'] = ''
236  if (search.nmatch >= 1 && search.range) {
237    var sel = search.range.getBoundingClientRect();
238    if (sel.right >= box.offsetLeft && sel.top <= box.offsetTop + box.offsetHeight) {
239      topval = (sel.bottom + 10);
240      box.style['top'] = ((topval < 100) ? topval : 100)  + 'px';
241    }
242  }
243  if (search.timeout && search.timeout > 0) {
244    if (search.timeout_id)
245      clearTimeout(search.timeout_id);  
246    search.timeout_id = setTimeout(function() {
247      clear_function(true); 
248    }, search.timeout * 1000);
249  }
250}
251
252function elementInViewport(el) {
253  var top = el.offsetTop;
254  var left = el.offsetLeft;
255  var width = el.offsetWidth;
256  var height = el.offsetHeight;
257
258  while(el.offsetParent) {
259    el = el.offsetParent;
260    top += el.offsetTop;
261    left += el.offsetLeft;
262  }
263
264  return (top >= window.pageYOffset &&
265          left >= window.pageXOffset &&
266          (top + height) <= (window.pageYOffset + window.innerHeight) &&
267          (left + width) <= (window.pageXOffset + window.innerWidth));
268}
269
270function processSearch(search, options) {
271  var selected = false;    
272  var selectedAnchor = getSelectedAnchor();
273  
274  if (selectedAnchor)
275    selectedAnchor.blur();
276  
277  if (search.text.length <= 0 || !search.mode) {
278    clearRanges();
279    return(selected);
280  }
281  
282  var matchedElements = new Array();
283  var string = escape_regexp(search.text).replace(/\s+/g, "(\\s|\240)+");
284  if (options.starts_link_only)
285    string = '^' + string;
286  // If string is lower case, search will be case-unsenstive.
287  // If string is upper case, search will be case-senstive.
288  var regexp = new RegExp(string, string == string.toLowerCase() ? 'ig' : 'g')
289  // currently Xpath does not support regexp matches. That would be great:
290  // document.evaluate('//a//*[matches(text(), "regexp")]', document.body, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null).snapshotItem(N);
291  var rootNodes = [window].concat(getRootNodes());
292  for (var i = 0; i < rootNodes.length; i++) {    
293    var doc = rootNodes[i].document || rootNodes[i].contentDocument;
294    if (!doc || !doc.body)
295      continue;
296    var frame = rootNodes[i].contentWindow || rootNodes[i];
297    var regexp2 = new RegExp(regexp);
298
299    function match(textNode) {
300      if (!textNode.data.match(regexp2))
301        return NodeFilter.FILTER_REJECT;
302      var anchor = up(textNode, 'a');
303      if ((search.mode == 'links' && !anchor) || 
304          !isVisible(textNode.parentNode) ||
305           up(textNode.parentNode, 'script'))
306        return NodeFilter.FILTER_REJECT;
307      return NodeFilter.FILTER_ACCEPT;
308    }
309
310    var nodeIterator = doc.createNodeIterator(doc.body, NodeFilter.SHOW_TEXT, match, true);
311      
312    while ((textNode = nodeIterator.nextNode()) != null) {
313      var anchor = up(textNode, 'a');
314      var option = up(textNode.parentNode, 'option');
315      var regexp2 = new RegExp(regexp);
316      var result;
317      while (match = regexp2.exec(textNode.data)) {
318        result = {doc: doc, frame: frame, node: textNode, 
319                  anchor: anchor, option: option, 
320                  start: match.index, end: regexp2.lastIndex};
321        matchedElements.push(result);
322      }          
323    }
324  }
325  
326  search.total = matchedElements.length;
327
328  if (matchedElements.length > 0) {
329    var index;
330    
331    // If this is the first search, start on the first match on viewport
332    if (search.search_in_viewport) {
333      index = 0;
334      while (index < matchedElements.length && 
335             !elementInViewport(matchedElements[index].node.parentNode)) {
336        index += 1;
337      }            
338    } else {
339      index = search.index;      
340    }    
341
342    index = index % matchedElements.length;
343    if (index < 0)
344      index += matchedElements.length;
345    var result = matchedElements[index];    
346    search.index = index;
347    search.nmatch = index + 1;
348    search.element = result.node.parentNode;
349    if (result.option) {
350      result.option.selected = 'selected';
351      search.select = up(result.option, 'select');
352    } else {
353      search.select = null;
354    }
355    clearRanges();
356    
357    var range = result.doc.createRange();    
358    range.setStart(result.node, result.start);
359    range.setEnd(result.node, result.end);
360    var selection = window.getSelection();
361    selection.addRange(range);
362    search.range = range;
363    selected = true;
364    if (result.anchor)
365      result.anchor.focus();
366    // Apparently A's with empty href don't trigger the window scroll when focused
367    if (!result.anchor || !result.anchor.href) {
368      var rect = range.getBoundingClientRect();      
369      var doc_height = window.innerHeight;
370      var zoom = get_current_zoom(result.doc);
371      if (zoom*rect.top < (doc_height * 1) / 6.0 || zoom*rect.bottom > (doc_height * 5) / 6.0) {  
372        var y = max(0, window.pageYOffset + zoom*rect.top - doc_height / 3.0);
373        window.scrollTo(window.pageXOffset + zoom*rect.left, y);
374      }
375    }
376  } else {
377    search.nmatch = 0;
378    clearRanges();
379  } 
380  
381  return(selected);
382}
383
384function is_shortcut(ev) {
385    var is_mac = navigator.appVersion.indexOf("Mac") !== -1;
386    var is_windows = navigator.appVersion.indexOf("Windows") !== -1;
387    var is_alternative_input = (is_mac && ev.altKey) || (is_windows && ev.ctrlKey && ev.altKey);
388    return !is_alternative_input && (ev.altKey || ev.metaKey || ev.ctrlKey);
389}
390
391function check_blacklist(sites_blacklist) {
392  if (sites_blacklist) {
393    var url = window.location.href;
394    var urls = options.sites_blacklist.split('\n');
395    for (var i=0; i < urls.length; i++) {
396      var s = urls[i].replace(/^\s+|\s+$/g, '');
397      if (s[0] == '#') {
398        // If URL starts with #, ignore it       
399        continue;
400      } else if (s[0] == '|') {
401        // If URL starts with | assume it is a real regexp.
402        var regexp = new RegExp('^' + s.slice(1) + '$');
403        if (url.match(regexp))
404          return true;
405      } else {
406      // Otherwise compare with the URL ('*' is the only wildcard available)
407        var s2 = escape_regexp(s, ["*"]).replace(new RegExp("\\*", "g"), ".*");
408        var regexp = new RegExp('^' + s2 + '$');
409        if (url.match(regexp))
410          return true;
411      }
412    }
413  }
414  return false;
415}
416
417function init(options) {
418  var keycodes = {
419    "backspace": 8,
420    "tab": 9,
421    "enter": 13,
422    "spacebar": 32,
423    "escape": 27,
424    "n": 78,
425    "p": 80,
426    "g": 71,
427    "f3": 114,
428    "f4": 115,
429  };
430  
431  var search = {};
432
433  function clearSearch(clearRanges) {
434    if (search.timeout_id) {
435      clearTimeout(search.timeout_id);
436    }
437    search = {mode: null, text: '', index: 0, search_in_viewport: true, 
438              matches: 0, total: 0, select: null, 
439              timeout: parseInt(options.timeout), options: options};
440    if (clearRanges) { 
441      selection = window.getSelection();
442      selection.removeAllRanges();
443    }
444    clearSearchBox();
445  }
446   
447  function processSearchWithOptions(blur_unless_found) {
448    return processSearch(search, { 
449      search_links: (search.mode == 'links'),
450      starts_link_only: options["starts_link_only"],
451      blur_unless_found: blur_unless_found
452    });    
453  }
454
455  function setEvents(rootNode) {
456    var doc = rootNode.contentDocument || rootNode;
457    var body = rootNode.body;
458    
459    if (!body || !body.addEventListener) {
460        return;
461    }
462
463    setAlternativeActiveDocument(doc);
464
465    doc.addEventListener('keydown', function(ev) {
466      if (isInputElementActive(doc))
467        return;      
468        
469      var code = ev.keyCode;
470      var selectedAnchor = getSelectedAnchor(doc);
471      var blacklisted = check_blacklist(options.sites_blacklist);
472
473      // Disable blacklisted sites completely
474      //if (blacklisted && !search.mode)
475      if (blacklisted)
476        return;      
477        
478      if (code == keycodes.backspace && search.mode) {
479        if (search.text) {
480          if (!ev.ctrlKey) {
481            search.text = search.text.substr(0, search.text.length-1);
482          } else { /* delete last word */              
483            var index = search.text.lastIndexOf(' ');
484            search.text = (index == -1) ? "": search.text.substr(0, index+1);
485          }        
486          processSearchWithOptions(true);
487          showSearchBox(search, clearSearchBox);
488        }
489      } else if (code == keycodes.escape && search.mode) {
490        clearSearch(true);
491        processSearchWithOptions(true);
492      } else if (code == keycodes.enter && 
493                 (selectedAnchor || selectOnchange(search.select))) {
494        clearSearch(true);
495        return;
496      } else if (search.mode && code == keycodes.tab && !selectedAnchor) {
497        var nodeIterator = doc.createNodeIterator(
498            doc.body,
499            NodeFilter.SHOW_ELEMENT,
500            null,
501            false
502        );
503        var textNode;
504        while ((textNode = nodeIterator.nextNode()) != search.element);
505        var nextNodeFuncName = ev.shiftKey ? "previousNode" : "nextNode"; 
506        while ((textNode = nodeIterator[nextNodeFuncName]()) != null) {
507          if ((textNode.tagName == 'A' && textNode.href) || 
508               textNode.tagName == "INPUT" || 
509               textNode.tagName == "TEXTAREA" ||
510               textNode.tagName == "SELECT") {
511            // Hack: If we directly textNode.focus() the cursor is not 
512            // set in the field. Why? As a workaround, defer to a timeout 
513            setTimeout(function() { textNode.focus(); }, 0);
514            break;
515          }
516        }
517        clearSearch(true);
518        stopEvent(ev);
519        return;
520      } else if (search.mode && code == keycodes.tab) {
521        clearSearch(true);
522        return;
523      } else if (code == keycodes.f4 && search.mode) {
524        search.mode = (search.mode == 'text') ? 'links' : 'text'
525        search.index = 0;
526        processSearchWithOptions(true);
527        showSearchBox(search, clearSearchBox);
528      } else if (search.text && (code == keycodes.f3 ||
529                                 (code == keycodes.g && (ev.ctrlKey || ev.metaKey)) ||
530                                 (code == keycodes.n && ev.altKey) ||
531                                 (code == keycodes.p && ev.altKey))) {
532        search.search_in_viewport = false; 
533        search.index += (ev.shiftKey || code == keycodes.p) ? -1 : +1;
534        processSearchWithOptions(true);
535        showSearchBox(search, clearSearchBox);
536      } else {
537        return;
538      }
539      
540      stopEvent(ev);
541    }, false);
542    
543    doc.addEventListener('keypress', function(ev) {
544      if (isInputElementActive(doc))
545        return;
546      var blacklisted = check_blacklist(options.sites_blacklist);
547      if (blacklisted)
548        return;
549      var code = ev.keyCode;
550      var ascii = String.fromCharCode(code);
551      if (!is_shortcut(ev) && ascii && [keycodes.enter].indexOf(code) == -1 &&
552          (code != keycodes.spacebar || search.mode)) {
553        if (!search.mode && ascii == "/") {
554          search.mode = 'text';
555        } else if (!search.mode && ascii == "'") {
556          search.mode = 'links';
557        } else if (!search.mode && blacklisted) {
558          return;
559        } else if (!search.mode && options.direct_search_mode == 'disabled') {
560          return;
561        } else if (!search.mode) {
562          search.mode = options.direct_search_mode == 'links' ? 'links' : 'text';
563          search.text += ascii;
564        } else if (search.mode) {
565          if (isSearchBoxVisible()) {
566            search.text += ascii;
567          } else {
568            search.text = ascii;
569          }
570        }
571        processSearchWithOptions(true)
572        showSearchBox(search, clearSearchBox);
573        stopEvent(ev)
574      }
575    }, false);
576 
577    doc.addEventListener('mousedown', function(ev) {
578      if (search.mode)
579        clearSearch(false);      
580    }, false);
581  }
582
583  clearSearch(false);
584  var rootNodes = [document].concat(getRootNodes());
585  for (var i = 0; i < rootNodes.length; i++) {
586    var rootNode = rootNodes[i];
587    if (rootNode.contentDocument) { 
588      rootNode.addEventListener('load', function(ev) {
589        setEvents(ev.target.contentDocument);
590      });
591    }
592    else if (!rootNode.contentDocument || rootNode.contentDocument.readyState == 'complete') {
593      setEvents(rootNode.contentDocument ? rootNode.contentDocument : rootNode);
594    }
595  } 
596}
597
598/* Default options */
599var default_options = {
600  direct_search_mode: 'text',
601  starts_link_only: false,
602  sites_blacklist: '',
603  color_link: '#DDF',
604  color_text: '#FF5',
605  color_notfound: 'F55'
606};
607
608function main(options) {
609  // Use setInterval to add events to document.body as soon as possible
610  interval_id = setInterval(function() {
611    if (document.body) {
612      clearInterval(interval_id);
613      init(options);
614    }
615  }, 100);
616}
617
618setAlternativeActiveDocument(document);
619options = default_options;
620
621if (typeof(chrome) == "object" && chrome.extension) {
622  chrome.extension.sendRequest({'get_options': true}, function(response) {
623    options = {
624      direct_search_mode: response.direct_search_mode,
625      sites_blacklist: response.sites_blacklist,
626      starts_link_only: (response.starts_link_only == '1'),
627      timeout: response.timeout,
628      color_link: response.color_link || default_options.color_link,
629      color_text: response.color_text || default_options.color_text,
630      color_notfound: response.color_notfound  || default_options.color_notfound
631    };
632    main(options);
633  });
634} else {  
635  main(options);
636}