MetaFilter Scroll Tag

By Plutor Last update Jul 18, 2008 — Installed 1,077 times. Daily Installs: 2, 1, 2, 0, 1, 1, 1, 0, 2, 1, 1, 2, 1, 0, 0, 0, 0, 1, 0, 3, 1, 1, 1, 0, 1, 2, 2, 1, 3, 0, 1, 0

Add Syntax Highlighting (this will take a few seconds, probably freezing your browser while it works)

// ==UserScript==
// @name           MetaFilter Scroll Tag
// @namespace      http://plutor.org/
// @description    Tracks your last-read comment in threads, and allows you to jump back to it easily.
// @include        http://metafilter.com/*
// @include        http://*.metafilter.com/*
// ==/UserScript==
//
// OPERA USERS:
// This script has been tested as working in Opera 9.26 for Windows.  You will need
// to also add the Greasemonkey builtin function emulator script available here:
//     <http://userjs.org/scripts/browser/enhancements/aa-gm-functions>
//
// DONE 2008-07-18
// * Add "nn since the last comment you read" links to the "recent activity" page
// TODO
// * Mark the last comment if scrolled to the bottom (Opera)
// * Indicate marked comment(s) for "recent activity" page
// * Allow mark moving on "recent activity" page

// ============================================================================
// Intrepid MeFites who are dissatisfied with the markers and jumpers I have
// provided can use these objects to change them.  Making either one wider than
// 75 pixels is not advised, as that is how wide the left border is on
// MetaFilter. You may use this website to create the base64-encoded URLs:
//   <http://www.sveinbjorn.org/dataurlmaker>s
//
var markerconf = {
    width: '35',     // pixels
    height: '15',
    img: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACMAAAAPCAYAAABut3YUAAAAAXNSR0IArs4c6QAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB9gDBA4sJYLQS4YAAAAddEVYdENvbW1lbnQAQ3JlYXRlZCB3aXRoIFRoZSBHSU1Q72QlbgAAAQ5JREFUSMfN1TFqhEAUxvF%2FYCEICaSzMGAKU60oOUGqbezSWbjX2ENs6xHiAewEm5ArRCyFKJmQUpFJ6aYKOJJkt3An%2B2CKYRjej2H4HsBO43oFbH6pM2BXVRU6SghBFEV10zT3QP0npiiKoyAMw8BxnL0gBbNareogCF7mxmRZdp3n%2Bd2%2BF1qML5mm%2BR7H8dPcGN%2F3H8Z7y7JIksSOouh5DFIwUkoJCB3%2F5yeQgun7XgJvaKopSMF0XacV8w3abrd2GIaPCqZt28%2ByLD90YoQQbDabGlgrmGEYzl3XvZq7oed5B%2BXOYnJ%2BCdz8VwBOMbfAeu7GUsqLQ5JYwaRpugSWx0jgQ0bCSc0mTmlqfwF0xNik%2FyQ5bQAAAABJRU5ErkJggg%3D%3D'
};
var jumperconf = {
    width: '15',
    height: '35',
    img: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAjCAYAAABLuFAHAAAAAXNSR0IArs4c6QAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB9gDBA8IJuvzka0AAAAddEVYdENvbW1lbnQAQ3JlYXRlZCB3aXRoIFRoZSBHSU1Q72QlbgAAAYJJREFUSMel1LFugkAYwPE%2FaKpEu3UzOWOYTTp2c%2BniO%2FAGDr6Kmy%2Fg4GuUJ2jqTCMaxi4KKJhyXdBoyx1SSAgH3%2F2%2B7wMODEDyz60J4HleZWjbNiY1tuZ5sFqtmE6nn0KIL9Xk7Xb7NJvNBsPh8BYDjMfj9%2Fl8%2FqHCk8nkGRj8qQwgpZTAtwpnWZYVtg1wOp1SIFbhPF6M0zRNgUiFkyRJynCownm8GB%2BPx6SkshofDoczPq8643qcx5VY23Ycx6VY2XYeL8ZRFCUlldU4DENt5f1%2Bn9yD5dXDuhzzeDHe7XbaFZbHi3GWZaZhGE3NV2gqMdACuhrcKsOPGtzW4XYJrlW5Vaeytu0G8KDBDR3WLk%2Bg%2BD1bloVt26%2BdTudFJaMo6lqWdTk3AFnnp%2B8HQVAJ5vN9Exg5jnN3giAIcBzHB0ZGfq0vhHhbLBb9Xq9XCjebzQjwjauYNsFvWJS8L4RYu64rPc%2B77K7rSiHEGuiX3dZNgirwJsFyuawMLwkAVwd%2FAGRf4dA3CSb%2BAAAAAElFTkSuQmCC',
    maxopacity: 1.0,   // Out of 1.0
    fadeindur: 0.25    // Seconds
};
//
// ============================================================================

var thread_id;
var markedcomment;
var comments = new Array();
var lastscroll;
var nextshow;
var markerdat;

//
// mst_init
//
// Determines if this is a thread or not, and sets everything up
//
function mst_init() {
    thread_id = mst_threadUniqName(location.href);

    if (thread_id) {
        // This is a thread
        mst_initThread();
    } else if (mst_isRecentActivity(location.href)) {
        mst_initRecentActivity();
    } else {
        // This is not a thread
        mst_findThreads();
    }
}

//
// mst_initThread
//
// Initializes everything if this is a thread view
//
function mst_initThread() {
    mst_initComments();
    mst_loadValue();
    mst_showJumper(0);
    document.addEventListener('scroll', function() { mst_hideJumper() }, false);
    document.addEventListener('scroll', function() { mst_findTopComment() }, false);
    mst_findTopComment(); // Just to get us started

    // Focusing the comment textarea should set the tag to the bottom
    var newcomment = document.getElementById('comment');
    if (newcomment) {
        var y = mst_documentHeight();
        newcomment.addEventListener('focus', function() { mst_findTopComment(y) }, false);
    }
}

//
// mst_documentHeight
//
// Returns the total height of the entire document, in pixels
//
function mst_documentHeight() {
    if (window.scrollMaxY != null) {
        // Firefox
        return window.scrollMaxY + window.innerHeight;
    } else if (document.documentElement.clientHeight) {
        // Opera
        return document.documentElement.clientHeight;
    }
}

//
// mst_scrollLocation
//
// Returns the scroll position of the top of the screen, in pixels
//
function mst_scrollLocation() {
    if (window.scrollY != null) {
        // Firefox
        return window.scrollY;
    } else if (document.body.scrollTop) {
        // Opera
        return document.body.scrollTop;
    }
}

//
// mst_scrollBottom
//
// Returns the scroll position of the bottom of the screen, in pixels
//
function mst_scrollBottom() {
    return mst_scrollLocation() + window.innerHeight;
}

//
// mst_threadUniqName
//
// Argument: url = the url of the thread
//
// Returns a uniq name of the thread: "$subdomain$threadid"
//
function mst_threadUniqName(url) {
    var urlm = url.match(/^http:\/\/([^\/]*\.)?metafilter.com\/(\d+)\//);
    if (urlm)
        return urlm[1] + urlm[2];
    return "";
}

//
// mst_initComments
//
// Finds all of the comment DOM objects and puts them into an array
// for easy future use
//
function mst_initComments() {
    var alldivs = document.getElementsByTagName('div');
    for (var i=0; i<alldivs.length; ++i) {
        if (alldivs[i].className.match(/comments/) &&
            alldivs[i].id != 'prevDiv' && alldivs[i].id != 'prevDiv2' &&
            alldivs[i].innerHTML.match(/posted by/)) {
            comments.push(alldivs[i]);
        } // is a comment
    } // loop divs
}

//
// mst_findThreads
//
// Finds all of the threads listed on the page, and replaces the '(mm new)'
// listings with ones that are more accurate.
//
function mst_findThreads() {
    var alldivs = document.getElementsByTagName('span');
    for (var i=0; i<alldivs.length; ++i) {
        if (alldivs[i].className == 'smallcopy' &&
            alldivs[i].innerHTML.match(/posted by/)) {
            var metabits = alldivs[i].childNodes;
            // First pass - delete 'mm new' links
            for (var j=0; j<metabits.length; ++j) {
                var bit = metabits[j];
                var m;
                if (bit.tagName && bit.tagName.toLowerCase() == 'b' &&
                    bit.innerHTML.match(/\d+ new/)) {
                    bit.parentNode.removeChild(bit);
                }
            } // loop bits of info

            // Second pass - add new 'mm new' links
            for (var j=0; j<metabits.length; ++j) {
                var bit = metabits[j];
                var m;
                if ( bit.tagName && bit.tagName.toLowerCase() == 'a' &&
                     (m = bit.innerHTML.match(/^(\d+) (comment|answer)s?$/)) ) {
                    var total = m[1];
                    var tid = mst_threadUniqName(bit.href);
                    if (tid) {
                        var markedid = GM_getValue(tid);
                        var markednum = GM_getValue(tid + "num");
                        var newc = total - markednum;
                        if (!markedid || !markednum) {
                            newc = total;
                            markedid = '';
                        }
                        if (newc > 0) {
                            // There are new comments
                            var newnew = document.createElement('b');
                            newnew.style.color = 'white';
                            newnew.style.marginLeft = '0.4em';
                            newnew.innerHTML = '(<a href="'
                                             + bit.href
                                             + '#'
                                             + markedid
                                             + '" target="_self" class="new">'
                                             + newc
                                             + ' new</a>)';
                            bit.parentNode.insertBefore( newnew,
                                                         bit.nextSibling );
                        }
                    }
                } // is a comments link
            } // loop bits of info
        } // is a thread
    } // loop divs

}

//
// mst_loadValue
//
// Argument: t = thread id (optional, defaults to global thread_id)
//
// Gets the comment id for the thread, finds the first comment after the
// anchor with that name, and marks it
//
function mst_loadValue(t) {
    if (!t) t = thread_id;
    var comment_id = GM_getValue(t);

    // Find the anchor
    var anchors = document.getElementsByTagName('a');
    for (var i=0; i<anchors.length; ++i) {
        if (anchors[i].name == comment_id) {
            // Find the comment div after the anchor
            var a = anchors[i];
            while (a && (!a.className || !a.className.match(/comments/) &&
                         a.id != 'prevDiv' && a.id != 'prevDiv2')) {
                a = a.nextSibling;
            }
            
            if (a) {
                markedcomment = a;
                mst_mark(a);
            }

            break;
        }
    }
}

//
// mst_findTopComment
//
// Argument: ysc = number of pixels from the top (optional, defaults to current position)
// Argument: dropped = is this is a search caused by a dropped marker?
//
// If dropped is false, finds the first comment below the scroll point. If it's
// below the currently marked comment, it marks it.
// If dropped is true, finds the last comment above the scroll point and marks it.
//
function mst_findTopComment(ysc, dropped) {
    if (ysc == null) ysc = mst_scrollLocation();
    var topc;

    if (!dropped) {
        // Scroll to the last comment if we're scrolled to the bottom of the document
        if (ysc >= window.scrollMaxY)
            ysc = mst_documentHeight();

        // Don't do anything if this is the same place we already were
        if (ysc == lastscroll) return;
    }

    lastscroll = ysc;

    // Assuming the comments array is in order
    for (var i=0; i<comments.length; ++i) {
        var c = comments[i];
        var cy = c.offsetTop;

        if (!topc)
            topc = c;

        if (dropped) {
            // Trying to find the last comment above ysc
            if ( cy <= ysc ) {
                topc = c;
            } else {
                break;
            }
        } else {
            // Trying to find the first comment below ysc
            topc = c;
            if ( cy >= ysc ) {
                break;
            }
        }
    }

    if (topc && (dropped || !markedcomment || markedcomment.offsetTop < topc.offsetTop)) {
        mst_mark(topc);
    }
}

//
// mst_mark
//
// Argument: c = DOM object of the comment to mark
//
// Moves the marker to the comment indicated, and saves the comment id
//
function mst_mark(c) {
    var marker = document.getElementById("mst_marker");
    if (!marker) {
        // Create it
        marker = document.createElement('div');
        marker.id = "mst_marker";
        marker.style.backgroundImage = 'url(' + markerconf.img + ')';
        marker.style.backgroundRepeat = 'no-repeat';
        marker.style.width = "" + markerconf.width + 'px';
        marker.style.height = "" + markerconf.height + 'px';
        marker.style.position = 'absolute';
        marker.style.cursor = 'move';

        document.body.appendChild(marker);
        marker.addEventListener('mousedown', mst_grabMarker, false);
    }

    // Mark - top aligned
    marker.style.top = "" + (c.offsetTop) + "px";
    marker.style.left = "" + (75/2 - markerconf.width/2) + "px";

    markedcomment = c;
    var comment_id = mst_commentIdOf(c);
    var comment_num = mst_commentNumOf(c);

    // Save
    if (thread_id && comment_id && comment_num) {
        GM_setValue(thread_id, comment_id);
        GM_setValue(thread_id + "num", comment_num);
    }
}

//
// mst_commentIdOf
//
// Argument: c = DOM object of the comment
//
// Returns the comment_id pulled from the name of the anchor immediately
// before the given comment
//
function mst_commentIdOf(c) {
    // Find the anchor right before the comment div
    while (c && (!c.tagName || c.tagName.toLowerCase() != 'a')) {
        c = c.previousSibling;
    }

    return (c) ? c.name : null;
}

//
// mst_commentIdOf
//
// Argument: c = DOM object of the comment
//
// Returns the index(+1) of the given comment in the comments array
//
function mst_commentNumOf(c) {
    for (var i=0; i<comments.length; ++i) {
        if (comments[i] == c) {
            return i+1;
        }
    }
    
    return 0;
}

//
// mst_showJumper
//
// Argument: fade = fraction from 0.0 to 1.0, defaults to 0
//
// Show the jumper with an opacity of $fade, calls itself with fade incremented
// by 0.1 until it's fully visible.
//
function mst_showJumper(fade) {
    if (!markedcomment) return;

    var winbottom = mst_scrollBottom();
    var commenty = markedcomment.offsetTop;

    if (commenty > winbottom) {
        // Show it
        var jumper = document.getElementById("mst_jumper");
        if (!jumper) {
            // Create it
            jumper = document.createElement('div');
            jumper.id = "mst_jumper";
            jumper.style.position = 'absolute';
            jumper.style.backgroundImage = 'url(' + jumperconf.img + ')';
            jumper.style.backgroundRepeat = 'no-repeat';
            jumper.style.height = "" + jumperconf.height + "px";
            jumper.style.width = "" + jumperconf.width + "px";
            jumper.style.cursor = 'pointer';

            var grabber = document.createElement('div');
            grabber.id = "mst_jumper_grabber";
            grabber.style.width = "" + jumperconf.width + "px";
            grabber.style.height = "" + (jumperconf.height / 3) + "px";
            grabber.style.position = 'relative';
            grabber.style.top = "0px";
            grabber.style.left = "0px";
            grabber.style.cursor = "move";

            jumper.appendChild(grabber);
            document.body.appendChild(jumper);

            jumper.addEventListener('mousedown', mst_grabMarker, false);
            jumper.addEventListener('click', mst_useJumper, false);
        }

        jumper.style.display = 'block';
        jumper.style.top = "" + (winbottom - jumperconf.height - 5) + "px";
        jumper.style.left = "" + (75/2 - jumperconf.width/2) + "px";

        if (!fade || fade < 0)
            fade = 0;

        // Amount to fade in by = 25msec / fadeindur;
        fade += (0.025 / jumperconf.fadeindur);

        if (fade < jumperconf.maxopacity) {
            jumper.style.MozOpacity = fade;
            jumper.style.opacity = fade;
            nextshow = setTimeout( mst_showJumper, 25, fade );
        } else {
            jumper.style.MozOpacity = jumperconf.maxopacity;
            jumper.style.opacity = jumperconf.maxopacity;
        }
    }
}

//
// mst_useJumper
//
// Jump to the marked comment. Called when the jumper is clicked.
//
function mst_useJumper() {
    // Go to the marked comment
    var id = mst_commentIdOf(markedcomment);
    if (id) location.href = "#" + id;
}

//
// mst_hideJumper
//
// Argument: keephidden = Don't schedule the showJumper call (defaults to 0)
//
// Hide the jumper immediately, and schedule a jumper re-show.  Keeps only a single
// re-show timer so it'll keep pushing it back until scrolling stops.
//
function mst_hideJumper(keephidden) {
    var jumper = document.getElementById("mst_jumper");

    if (jumper) {
        jumper.style.display = 'none';
    }

    if (nextshow) {
        clearTimeout(nextshow);
        nextshow = null;
    }

    if (!keephidden) nextshow = setTimeout( mst_showJumper, 350, 0 );
}

//
// mst_grabMarker
//
// Called when the marker or jumper are first grabbed for moving
//
function mst_grabMarker(e) {
    var grabber = document.getElementById('mst_marker');

    // Remove the mousedown event
    grabber.removeEventListener('mousedown', mst_grabMarker, false);

    // Add mousemove/mouseup events
    document.body.addEventListener('mousemove', mst_moveMarker, false);
    document.body.addEventListener('mouseup', mst_dropMarker, false);

    // Record where we started
    markerdat = {
                  x: e.pageX,
                  y: e.pageY,
                  g: grabber,
                  init: 0
                };

    e.preventDefault();
    return false;
}

//
// mst_moveMarker
//
// Called when a grabbed marker is moved. Moves the marker to where the mouse is,
// and tracks the location in the markerdat object.
//
function mst_moveMarker(e) {
    var x = e.pageX;
    var y = e.pageY;

    // Is this the initial movement
    if (!markerdat.init) {
        // Is initial movement sufficient?
        if ( Math.abs(markerdat.x-x) + Math.abs(markerdat.y-y) > 15 ) {
            // Hide the jumper, don't start redisplay timer
            markerdat.init = 1;
            mst_hideJumper(1);
        }
    }

    // Show the marker under the mouse
    if (markerdat.init) {
        var marker = document.getElementById('mst_marker');
        marker.style.MozOpacity = '0.7';
        marker.style.opacity = '0.7';
        marker.style.top = "" + (y - markerconf.height/2) + "px";
        marker.style.left = "" + (x - markerconf.width/2) + "px";
    }

    e.preventDefault();
    return false;
}

//
// mst_dropMarker
//
// Called when a grabbed marker is dropped. Finds the nearest comment, and marks it.
//
function mst_dropMarker(e) {
    // Did we ever have movement?
    if (markerdat.init) {
        var marker = document.getElementById('mst_marker');
        marker.style.MozOpacity = '1.0';
        marker.style.opacity = '1.0';

        // Record the new marked location
        mst_findTopComment(e.pageY, 1);
    }

    mst_showJumper(0);

    // Re-add the mousedown 
    markerdat.g.addEventListener('mousedown', mst_grabMarker, false);

    // Remove mousemove/mouseup events
    document.body.removeEventListener('mousemove', mst_moveMarker, false);
    document.body.removeEventListener('mouseup', mst_dropMarker, false);
}

//
// mst_isRecentActivity
//
function mst_isRecentActivity(url) {
    return (url.indexOf("http://www.metafilter.com/contribute/activity/") == 0);
}

function mst_initRecentActivity() {
    var alldivs = document.getElementsByTagName('div');
    for (var i=0; i<alldivs.length; ++i) {
        if (alldivs[i].style.fontWeight == 'bold' &&
            (m = alldivs[i].innerHTML.match(/(\d+) total comments.*most recent comment/))) {
            var total = m[1];
            var thisdiv = alldivs[i];
            var metabits = thisdiv.childNodes;

            for (var j=0; j<metabits.length; ++j) {
                var bit = metabits[j]

                if ( bit.tagName && bit.tagName.toLowerCase() == 'a' ) {
                    var tid = mst_threadUniqName(bit.href);
                    var url = bit.href.replace(/#\d+$/, '');
                    if (tid) {
                        var markedid = GM_getValue(tid);
                        var markednum = GM_getValue(tid + "num");
                        var newc = total - markednum;
                        if (!markedid || !markednum) {
                            newc = total;
                            markedid = '';
                        }
                        if (newc > 0) {
                            // There are new comments
                            var newdiv = document.createElement('div');
                            newdiv.style.fontWeight = 'bold';
                            newdiv.innerHTML = thisdiv.innerHTML.replace(/ total comments\..*/, "") +
                                " total comments.  " + newc + " since ";
                            
                            
                            var newcount = document.createElement('a');
                            newcount.target = bit.target;
                            newcount.href = url + '#' + markedid;
                            newcount.innerHTML = 'the last comment you read';
                            newdiv.appendChild( newcount );

                            newdiv.innerHTML += ".  " +
                                thisdiv.innerHTML.replace(/.* total comments\./, "")
                            
                            thisdiv.parentNode.replaceChild(newdiv, thisdiv);
                        }
                        break;
                    }
                } // is a comment link
            }
        } // is a thread header
    }
}

//
// Start by initializing
//
mst_init();