martixy / Reddit Comment Navigator

// ==UserScript==
// @name       Reddit Comment Navigator
// @namespace  http://www.reddit.com/
// @version    1.01
// @description  Used for nagivation of reddit comment threads
// @match      http://www.reddit.com/r/*/comments/*
// @match      http://www.reddit.com/user/*
// @require    http://ajax.googleapis.com/ajax/libs/jquery/1/jquery.min.js
// @require    http://martixy.no-ip.biz/CDN/jq.hotkeys-0.1.0/jquery.hotkeys.js
// @copyright  2014+, martixy
// @license    GPLv2 | gnu.org/licenses/gpl-2.0.html
// ==/UserScript==

// USAGE GUIDELINES
// Simple really:
// J collapses the next top-level comment
// K expands the next top-level comment
// Left - Collapse/Move to parent
// Right - Expand/Move to child
// Up/Down - Next/Prev sibling

// Script works on both comment pages and user overview pages. Maybe in more places too, but you'll have to add those in the pattern matching up top.


// Changelog:
// v1.01
// * Small filter fix


// Known issues:
// Sometimes it doesn't skip over pre-collapsed comments. No idea why. Line 127 seems to fail in some manner


//TIL: You have to define it like this to be able to call it via dot(a.k.a. chain it).
var extendjQuerythingamajig = function ()
{
    $.fn.thing = function() {
        return this.hasClass("thing") ? this : this.parents(".thing:first");
    },

    //Debug bit
    $.fn.box = function() {
        return this.css("border", "2px solid red");
    },

    // Original function: Timothy Perez, modified by me
    // http://stackoverflow.com/a/12742737/802363
    // http://lions-mark.com/jquery/scrollTo/
    $.fn.scrollTo = function( target, options, callback ){
      if(typeof options == 'function' && arguments.length == 2){ callback = options; options = target; }
      var settings = $.extend({
        scrollTarget  : target,
        offsetTop     : 20,
        duration      : 200,
        easing        : 'swing',
        lazyThreshold : 0.7
      }, options);
      return this.each(function(){
        var scrollPane = $(this);
        var scrollY = 0;
        // var scrollTarget = (typeof settings.scrollTarget == "number") ? settings.scrollTarget : $(settings.scrollTarget);
        // var scrollY = (typeof scrollTarget == "number") ? scrollTarget : scrollTarget.offset().top - parseInt(settings.offsetTop);// + scrollPane.scrollTop() - parseInt(settings.offsetTop);
        if(typeof settings.scrollTarget == "number") {
            scrollY = settings.scrollTarget;
        }
        else {
            var scrollTarget = $(settings.scrollTarget);
            if((scrollTarget.offset().top > $(window).scrollTop() + $(window).height() * settings.lazyThreshold) || scrollTarget.offset().top < $(window).scrollTop()) {
                scrollY = scrollTarget.offset().top - parseInt(settings.offsetTop);
            }
        }
        if(scrollY != 0)
        scrollPane.animate({scrollTop : scrollY }, parseInt(settings.duration), settings.easing, function(){
          if (typeof callback == 'function') { callback.call(this); }
        });
      });
    }
}();


var allThings = $(".thing");
var first = allThings.filter(".comment:first");
var last = allThings.filter(".comment:last");
var post = allThings.filter(".link");
var toplevel = first;
var current = first;
var next = current;
var i = 0;

// Needs a marker (half-transparent in some corner)
var markerDiv = $("<div></div>");
var markerStyle = {
    "position"          : "fixed",
    "bottom"            : "20px",
    "left"              : "20px",
    "width"             : "15px",
    "height"            : "15px",
    "border-radius"     : "50%",
    "background-color"  : "#369",
    "opacity"           : "0.5"
}
markerDiv.css(markerStyle);

// Custom bits cuz mixing mouse and keyboard navigation can mess up the internal state
var customClassCollapsed = "navigator-collapsed";

$(".comment").each(function() {
    if(isCollapsed($(this)))
        $(this).addClass(customClassCollapsed);
});

$(".expand").bind("click", function() {
    $(this).thing().toggleClass(customClassCollapsed);
    $(this).thing().children().find(".thing").toggleClass(customClassCollapsed);
});


function isCollapsed(e) {
    return $(e).children(".entry").find(".collapsed").css("display") == "block";
}

// For arrow-key navigation
var highlightCSS = {
    "text-decoration"  : "none",
    "color"            : "white",
    "background-color" : "#369"
}

function highlight(e) {
    if(!$(e).hasClass("link"))
        $(e).children(".entry").find(".expand").css(highlightCSS);
    else
        $(e).children(".entry").find(".comments").css(highlightCSS); // Highlights the string that says how many comments there are
}

function delight(e) {
    $(e).children(".entry").find(".expand, .comments").attr("style","");
}

//==================================
//   Keybinds
//==================================
$(document).bind("keydown", "J", function() {
    if(first.find(".noncollapsed").css("display") == "block") {
        hidecomment(first);
    }
    else {
        toplevel = first.siblings(".thing").children(".entry").find(".noncollapsed").filter(function() {
     		return $(this).css("display") == "block";
		}).first().thing();
        hidecomment(toplevel);
    }
});

$(document).bind("keydown", "K", function() {
    toplevel = first.siblings(".thing").children(".entry").find(".collapsed").filter(function() {
        return $(this).css("display") == "block";
    }).last().thing();
    if(toplevel.length == 0) {
        showcomment(first);
    }
    else {
        showcomment(toplevel);
    }
});


// TIL:
// Bad: $(".class:first")
// Good: $(".class").first()
// Better: $("div.class").filter(":first")
// Best: $(".class").filter(":first")
// 1. Use jQ methods to reduce your set(not selectors)
// 2. Be specific on the right-hand side of your selector, and less specific on the left.
$(document).bind("keydown", "up", function() {
    if(i == 0) {
        console.log("up");
        current = allThings.filter(".link"); //Be sneaky (a.k.a. select post itself so below code will wrap up and start from bottom)
        i = 1;
        $( "body" ).append(markerDiv);
    }
    // When does it deviate from index navigation:
    // 1. When it's on a deeply collapsed node
    if(current.parents(".thing").filter(":first").hasClass(customClassCollapsed)) {
        console.log("up:   step-out");
        next = current.parents("." + customClassCollapsed).last(); //Get topmost collapsed node
    }
    // 2. When it's wrapping up
    else if(current.hasClass("link")) {
        console.log("up:   wrap");
        next = allThings.eq(-1);
    }
    else {
        console.log("up:   step-index");
        next = allThings.eq(allThings.index(current) - 1);
    }
    // 3. When it's about to enter a deeply collapsed node, but not when exiting it!
    if(next.hasClass(customClassCollapsed) && !current.parents(".thing").filter(":first").hasClass(customClassCollapsed) && next.parents(".thing").length > 0) { // So check if we've done so and exit to topmost collapsed. Also for the love of god - sanity check
        console.log("up:   step-over");
        next = next.parents("." + customClassCollapsed).last();
    }

    $("body").scrollTo(next);
    delight(current);
    highlight(next);
    current = next;

    return false; //Override the default action
});

$(document).bind("keydown", "down", function() {
    if(i == 0) {
        console.log("down");
        current = allThings.filter(".link"); //Be sneaky (a.k.a. select post itself so below code will wrap up and start from top)
        i = 1;
        $( "body" ).append(markerDiv);
    }
    //next = current; //Does have to be true, need not be explicit unless you mess with it
    if(current.hasClass(customClassCollapsed)) { //We're on a collapsed node right now
        if(current.parents(".thing").filter(":first").hasClass(customClassCollapsed)) { //In fact we're very deep inside
            next = current.parents("." + customClassCollapsed).last(); //Get topmost collapsed node
            console.log("down: step-out");
        }
        // We're on the topmost collapsed node right now (this guarantees that its sibling nodes and any of its parents sibling nodes are visible and are therefore valid navigation targets)
        if(next.nextAll(".thing").length == 0) { // but this node does not have any siblings for us to navigate to
            next = next.parents(".thing").filter(function(index) { //So we take a list of the node's parents
                return $(this).nextAll(".thing").length > 0; // We filter only for parents that have siblings
            }).filter(":first"); //We return the first parent that has siblings
            console.log("down: step-over");
        }
        if(next.nextAll(".thing").length == 0) { //If we have not found a higher node with siblings this means we're at the end of the thread && current == next
            next = post; // Therefore wrap around
            console.log("down: wrap-sibling");
        }
        else {
            next = next.nextAll(".thing").filter(":first"); //Otherwise get the returned node's next sibling
            console.log("down: step-sibling");
        }
    }
    else if(allThings.index(current) + 1 == allThings.length) {
        next = post;
        console.log("down: wrap-index");
    }
    else {
        next = allThings.eq(allThings.index(current) + 1);
        console.log("down: step-index");
    }

    $("body").scrollTo(next);
    delight(current);
    highlight(next);
    current = next;

    return false; //Override the default action
});

$(document).bind("keydown", "right", function() {
    if(i == 1) {
        console.log("right");
        if(isCollapsed(current)) {
            showcomment(current);
        }
        else {
            next = current.children(".child").find(".thing:first");
            if(next.length > 0) {
                $("body").scrollTo(next);
                delight(current);
                highlight(next);
                current = next;
            }
        }
    }

    return false; //Override the default action
});

$(document).bind("keydown", "left", function() {
    if(i == 1) {
        console.log("left");
        if(!isCollapsed(current)) {
            hidecomment(current);
        }
        else if(!current.parent().hasClass("nestedlisting")) {
            next = current.parents(".thing:first");
            $("body").scrollTo(next);
            delight(current);
            highlight(next);
            current = next;
        }
    }

    return false; //Override the default action
});

$(document).bind("keydown", "esc", function() {
    if(i == 1) {
        console.log("esc");
        delight(current);
        i = 0;
        $(markerDiv).remove();
    }
    return false; //Override the default action
});



// Shameless copy cuz I don't wanna deal with injecting myself in the page. Encapsulation win I guess?
// Did modify to add my custom class
function hidecomment(t) {
    //var t = $(e).thing();
    return t.hide().find(".noncollapsed:first, .midcol:first").hide().end().show().find(".entry:first .collapsed").show(), t.hasClass("message") ? $.request("collapse_message", {
        id: $(t).thing_id()
    }) : t.find(".child:first").hide(), t.addClass(customClassCollapsed), t.children().find(".thing").addClass(customClassCollapsed), !1
}

function showcomment(t) {
    //var t = $(e).thing();
    return t.find(".entry:first .collapsed").hide().end().find(".noncollapsed:first, .midcol:first").show().end().show(), t.hasClass("message") ? $.request("uncollapse_message", {
        id: $(t).thing_id()
    }) : t.find(".child:first").show(), t.removeClass(customClassCollapsed), t.children().find(".thing").removeClass(customClassCollapsed), !1
}

// Disregard for now
function thing_id(t) {
    t = e.with_default(t, "thing");
    var n = this.hasClass("thing") ? this : this.thing();
    t != "thing" && (n = n.find("." + t + ":first"));
    if (n.length) {
        var r = e.grep(n.get(0).className.match(/\S+/g), function (e) {
            return e.match(/^id-/);
        });
        return r.length ? r[0].slice(3, r[0].length) : "";
    }
    return "";
}

function with_default(t, n) {
    return e.defined(t) ? t : n;
}

function defined(e) {
    return typeof e != "undefined";
}