NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript==
// @name Github Matches Highlighter
// @version 0.3
// @description Highlight all matches of the clicked on word in the Github code viewer
// @include https://github.com/*
// @copyright 2014, Chin
// @run-at document-end
// @grant none
// ==/UserScript==
// This script uses jQuery, but does not include it, since Github already uses it
var cfg = {};
var fns = {};
/**
* Add a css style element to <head>
*/
function addGlobalStyle(css) {
var head = document.getElementsByTagName('head')[0];
if (!head) {
return;
}
var style = document.createElement('style');
style.type = 'text/css';
style.innerHTML = css;
head.appendChild(style);
}
/**
* Highlight all matches of the clicked on word
*/
function highlightMatch() {
var codeContent = document.getElementsByClassName('highlight js-file-line-container')[0];
// revert all highlighted text back to their previous state
var highlighted = codeContent.getElementsByClassName('match_highlighted');
while (highlighted.length !== 0) {
var span = highlighted[0];
span.parentNode.replaceChild(document.createTextNode(span.textContent), span);
}
// Gets clicked on word
var t = '';
// Webkit, Gecko
var s = window.getSelection();
if (s.isCollapsed) { // don't care when the user highlight text manually
s.modify('move', 'forward', 'character');
s.modify('move', 'backward', 'word');
s.modify('extend', 'forward', 'word');
t = s.toString();
t = t.replace(/[^\w\s]/gi, '').trim();
s.modify('move', 'forward', 'character'); //clear selection
var n, allTextNodes = [],
walk = document.createTreeWalker(codeContent, NodeFilter.SHOW_TEXT, null, false);
while (n = walk.nextNode()) {
allTextNodes.push(n);
}
for (var i = 0; i < allTextNodes.length; i++) {
n = allTextNodes[i];
var replaceNodes = processTextNode(n, t);
var parentNode = n.parentNode;
parentNode.replaceChild(replaceNodes[replaceNodes.length - 1], n);
var referenceElement = replaceNodes[replaceNodes.length - 1];
for (var k = 0; k < replaceNodes.length - 1; k++) {
parentNode.insertBefore(replaceNodes[k], referenceElement);
}
}
// normalize the document, i.e. merging adjacent text nodes
codeContent.normalize();
// clear the current markers on the indicator
fns.codenav_clear_marks();
// add a marker for each highlighted span
highlighted = $('.match_highlighted');
for (i = 0; i < highlighted.length; i++) {
var tok = highlighted[i];
// grab the line number
var lineno = parseInt($(tok).closest('td').attr('id').slice(2));
fns.codenav_mark_line(lineno, tok);
}
// log the clicked on word for debugging purpose
console.log("\"" + t + "\"");
}
}
/**
* Given a textNode and a string toFind, returns an array of text nodes/spans such that each occurance
* of the toFind in textNode's text is turned into a ".match_highlighted" span
*/
function processTextNode(textNode, toFind) {
var text = textNode.textContent;
var nodes = [];
var indexLeft = text.indexOf(toFind);
if (indexLeft != -1) {
if (indexLeft > 0) {
// process left node. We can be sure that there's no occurance of toFind in leftText, so just push it in as-is
var leftText = text.substring(0, indexLeft);
nodes.push(document.createTextNode(leftText));
}
var span = document.createElement('span');
span.innerHTML = toFind;
span.className = "match_highlighted";
nodes.push(span);
if (indexLeft + toFind.length < text.length) {
// todo: process the right part properly
var rightText = text.substring(indexLeft + toFind.length);
nodes.push(document.createTextNode(rightText));
}
} else {
nodes.push(textNode);
}
return nodes;
}
/**
* Try to find the code viewer and bind the onClick event to it.
* Also setup the indicator bar.
*/
function trySetup() {
var codeContent = document.getElementsByClassName('highlight js-file-line-container')[0];
if (codeContent) {
if (!codeContent.onclick) {
codeContent.onclick = highlightMatch;
console.log("onClick registered");
setup_config();
setup_scroll_bar();
setup_scroll_bar_positioning();
setTimeout(function() {
$(window).trigger('scroll.codenav');
}, 100);
console.log("Setup done");
}
} else {
// console.log("Code viewer not found");
}
}
/**
* Add the necessary css rules
*/
function setupCss() {
addGlobalStyle('.match_highlighted { background-color: rgba(253, 255, 0, 0.28); }');
addGlobalStyle('.codenav_scroll_indicator { width: 10px; vertical-align: top; text-align: right; line-height: 1; float: right; \
margin-top: 3px; margin-bottom: 3px; top: 45px; right: 12px; position: absolute; z-index: 999999; }');
addGlobalStyle('.codenav_scroll_indicator_mark { position: absolute; background-color: #ffa; border: 1px solid #909090; \
width: 10px; height: 8px; float: right; cursor: pointer;}');
}
function setup_config() {
cfg.original_scroll_pos = $(window).scrollTop();
cfg.$code_body = $('.js-file-line-container');
var font_size = cfg.$code_body.css('font-size');
cfg.line_height = font_size ? Math.floor(parseInt(font_size.replace('px', '')) * 1.5) : 19;
}
function setup_scroll_bar() {
// Manual width is to fix firefox problem.
var $scrollindicator = $('<div class="codenav_scroll_indicator"></div>')
.appendTo($('.js-file-line-container').parent());
var $bwrapper = $('.blob-wrapper');
var total_num_lines = $('.js-line-number').length; // total lines in file
var did_set_border = false;
// Define marking functions.
fns.codenav_mark_line = function(n, $elt) {
// Reset height to handle resize
var $bwrapper = $('.blob-wrapper');
$scrollindicator.height(Math.min($(window).innerHeight(), $bwrapper.height()));
if (!did_set_border) {
$bwrapper.css('border-right', '14px solid rgba(0, 0, 0, 0.04)');
did_set_border = true;
}
// Compute marker position
var height;
if ($('body').height() > $(window).height()) {
// Has scroll bar.
height = Math.round((n / total_num_lines) * 100) + '%';
} else {
// Handle the special case where the document fits within the entire window.
height = (cfg.line_height * n - 20) + 'px';
}
var $mark = $('<span class="codenav_scroll_indicator_mark"></span>')
.appendTo($scrollindicator)
.css('top', height)
// Fix positioning if code is horizontally scrollable
//.css('margin-left', -1*Math.max(0, $fcode.width() - 920 + 11))
.on('click', function() {
// Note this doesn't handle resize between setup and click.
scroll_to_lineno(n);
});
};
fns.codenav_clear_marks = function() {
$('.codenav_scroll_indicator_mark').remove();
};
}
/**
* As we scroll past the top of the file code container, attach the line marker container to be
* fixed in the viewport (& reset it to be contained in the file container if we scroll back up.)
*/
function setup_scroll_bar_positioning() {
// This function is called on pjax page loads (where the window object persists but the page
// content changes), so first unregister any old window event handlers before adding new ones
$(window)
.off('scroll.codenav')
.off('resize.codenav');
if (!is_code_page()) {
return;
}
var $bwrapper = $('.blob-wrapper');
var $scrollindicator = $('.codenav_scroll_indicator');
// Cache the current 'position' attribute of $scrollindicator to save a CSS lookup/set each scroll
var last_position = null;
// On page scroll, check if the $scrollindicator container holding our line markings should be
// attached to its parent like a normal element, or fixed in the viewport as we scroll down
$(window).on('scroll.codenav', function() {
var amount_scrolled_below_top_of_bwrapper = $(window).scrollTop() - $bwrapper.offset().top;
var amount_scrolled_below_bottom_of_bwrapper = amount_scrolled_below_top_of_bwrapper +
$(window).height() - $bwrapper.height();
if (amount_scrolled_below_top_of_bwrapper > 0) {
// If we've scrolled past the top of the code blob container, fix $scrollindicator to viewport
if (last_position !== 'fixed') { // Only update CSS attributes if not already set correctly
$scrollindicator
.css('position', 'fixed')
// We don't need to add padding for the file header bar because it's scrolled offscreen
// at this point
.css('top', '0px')
.css('left', Math.round($bwrapper.offset().left + $bwrapper.width() - 7) + 'px');
last_position = 'fixed';
}
} else {
// If we're above the top of the code blob container, attach $scrollindicator to it
if (last_position !== 'absolute') {
$scrollindicator
.css('position', 'absolute')
// We add 45px of padding above it to account for the file header info/actions bar
.css('top', '45px')
.css('left', 'auto');
last_position = 'absolute';
}
}
if (amount_scrolled_below_bottom_of_bwrapper > 0) {
$scrollindicator.height($(window).innerHeight() - amount_scrolled_below_bottom_of_bwrapper);
} else {
$scrollindicator.height($(window).innerHeight());
}
});
// We resize the $scrollindicator container to be the visible height of the blob wrapper
$(window).on('resize.codenav', function() {
$scrollindicator.height($(window).innerHeight());
$(window).trigger('scroll.codenav');
});
}
function scroll_to_lineno(n) {
var $bwrapper = $('.blob-wrapper');
var $lineelt = $('#LC' + n);
var linepos = $lineelt.offset().top;
var margin = Math.round($lineelt.height() / 3);
$('html, body').animate({
scrollTop: (linepos - margin)
});
}
function is_code_page() {
return $('#LC1').length > 0;
}
setupCss();
// Try once after page loads
trySetup();
// This is needed since Github uses AJAX to load new page contents, so we need to constantly monitoring the page
setInterval(function() {
trySetup();
}, 2000);