/type-ahead.js
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>') : ' '; 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}