Reddit Comments By Level

By Pranav K Last update Jul 24, 2010 — Installed 759 times.

There are 9 previous versions of this script.

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

// ==UserScript==
// @name           Reddit Comments By Level
// @namespace      http://www.reddit.com
// @description    Navigate reddit comments by levels with the keyboard
// @include 	    http://*reddit.com*
// @exclude	    http://*reddit.com/prefs*
// @author         Pranav K

// ==/UserScript==

(function() {
	//Specify modifiers at the beginning and separated by + (for instance Ctrl+Shift). I think the command key would behave like the Ctrl key on a Mac
	//Alt key won't work. Separate the modifiers and the actual key with a hyphen '+'. 
	//Use Pascal casing (e.g. PageUp, PageDown) for keys with spaces 
	var CommentKeyMappings = {
		previousComment		: 'Up', 		//Move to the previous visible comment
		nextComment			: 'Down',	 	//Move to the next visible comment
		collapseLevel			: 'Left',	 	//Collapse this level and return to the parent of this comment. Does nothing for top level comments
		expandLevel 			: 'Right',	 	//Expand the responses of this comment and move to the first response. Will also expand collapsed comments 
		upvote 					: 'Ctrl+Up',	 	//Mmmm, orangered
		downvote	 			: 'Ctrl+Down', 	//Down votes comments or un-downvotes previously downvoted comments. Just like clicking the normal downvote arrow
		reply						: 'Ctrl+Right',	//Reply to a comment
		toggleKeyTraversal	: '.'		 	//Restores the page to its former glory and stops following you around. 
	};
	
	var PageKeyMappings = {
		previousPost			: 'Up', 			//Move to the previous post
		nextPost				: 'Down',			//Move to the next post
		previousPage			: 'Q',	//Move to the next page
		nextPage				: 'W',	//Move to the next page
		upvote					: 'Ctrl+Up',		//Toggle the upvote
		downvote				: 'Ctrl+Down',	//Toggle the downvote
		comments				: 'Right',			//Go to the comments page
		gotoPost				: 'L',				//Go to the post (the page link)
		gotoPostAndComments	: 'Ctrl+L',			//Open post in a new window. Also opens the comments page.
		expandPreview		: 'E' 				//Expand the in-reddit view for youtube videos, vimeo etc
	};
	
	var MoreChildrenText = ' ...'; //An alternative that would be nice: <sub style="font-size: 8px">[more...]</sub>
	
	
	var KeyParser = {
		specialKeys: {PageUp: 33, PageDown: 34, End: 35, Home: 36, Left: 37, Up: 38, Right: 39, Down: 40, Insert: 45, Delete: 46,
							';': 186, '=': 187, ',': 188, '-': 189, '.': 190, '/': 191},
		CtrlValue: 9001,
		ShiftValue: 5000,

		parseKeyMap: function(key){
			var tokens = key.split('+');
			var keys, modifiers;
			if(tokens.length == 0)
				return;
			else if(tokens.length == 1){
				keys = tokens[0];
				modifiers = [];
			}else{
				var modifiers = tokens[0].split('+');
				var key = tokens[1];
			}
			
			var keyValue = 0;
			for(var i = 0; i < modifiers.length; i++){
				if(modifiers[i] == 'Ctrl')
					keyValue += this.CtrlValue;
				else if(modifiers[i] == 'Shift')
					keyValue += this.ShiftValue;
			}
			var value = this.specialKeys[key];
			if(value){
				keyValue += value;
			}else
				keyValue += key.toUpperCase().charCodeAt(0);
			return keyValue;
		},
		
		parseKeyEvent: function(keyEvent){
			var keyValue = 0;
			if(keyEvent.ctrlKey || keyEvent.metaKey)
				keyValue += this.CtrlValue;
			if(keyEvent.shiftKey)
				keyValue += this.ShiftValue;
			keyValue += keyEvent.keyCode;
			return keyValue;
		}
	};
	
	var CommentKeyTraverser = function(root){
		var _current;
		var _mappings = {};
		var topLevelComments = [];
		var _enabled;
		var _scroller = new Scroller();
		var _scrollItem;
			
		function _init(){
			_scrollItem = getScrollItem();
			_scroller = new Scroller();

			for(var k in CommentKeyMappings){
				var keys = CommentKeyMappings[k];
				_mappings[KeyParser.parseKeyMap(keys)] = eval("(function(){ return " + k + "})()");
			}
			start();
			document.addEventListener('keydown', keyTraverser, false);
			if(_scrollItem.scrollTop > 0)
				moveTo(getFirstVisible(), false);
			else
				moveTo(root.querySelector('.thing'), false);
		}
		
		function toggleKeyTraversal(){
			if(_enabled){
				stop();
			}else{
				start();
				
				//If the person re-enables, we will try and focus somewhere in the vicinity of where the page was
				var firstVisible = getFirstVisible();
				if(firstVisible){
					while(firstVisible.parentNode.parentNode != root)
						firstVisible = parentThing(firstVisible, root);
					moveTo(firstVisible);
				}
			}
		}
		
		function start(startOption){
			_enabled = true;
			document.addEventListener('scroll', trackScroll, false);
			setupPage();
		}
		
		function stop(){
			_enabled = false;
			document.removeEventListener('scroll', trackScroll, false);
			if(_current)
				removeFocus(_current);

			var elements = root.querySelectorAll('.thing');
			for(var i = 0; i < elements.length; i++){
				var element = elements[i];
				var listItem = element.querySelector('ul.buttons > li.meow');
				if(listItem){
					listItem.parentNode.removeChild(listItem);
					lisItem = null;
				}
					element.parentNode.style.display = "";
			}
			if(_current)
				_scrollItem.scrollTop = _current.offsetTop - 100;
			_current = null;
			_scroller.stopScroll();
		}
		
		function setupPage(){
			var elements = root.querySelectorAll('.thing');
			for(var i = 0; i < elements.length; i++){
				var element = elements[i];
				if(element.parentNode.parentNode != commentArea){
					element.parentNode.style.display = "none";
				}else
					topLevelComments.push(element);
				var listItem = document.createElement('li');
				listItem.className = 'meow';
				var link = document.createElement('a');
				link.href = "javascript:void(0);";
				link.innerHTML = 'here' + ((element.querySelector('.child').childNodes.length > 0) ? MoreChildrenText : '');
				link.addEventListener('click', function(e){
					return function(){
						moveTo(e, false);
					};
				}(element), false);
				listItem.appendChild(link);
				element.querySelector('ul.buttons').appendChild(listItem);
			}
		}
		
		_mappings = {};
		
		function keyTraverser(keyEvent){
			if(keyEvent.altKey || (/^(TEXTAREA)|(SELECT)|(INPUT)$/.test(keyEvent.target.tagName)))
				return;
			var keyValue = KeyParser.parseKeyEvent(keyEvent);
			if(_mappings[keyValue]){
				if(!_enabled && (_mappings[keyValue] !== toggleKeyTraversal))
					return;
				_mappings[keyValue]();
				keyEvent.preventDefault();
			}
		}
		
		function trackScroll(mouseEvent){
			//We want to avoid calculating offsets every time. So we'll simply wait until the scroll stabilizes by checking
			//if the last scrolled offset is the current page scroll offset
			function waitForScroll(offsetY){
				if(offsetY == _scrollItem.scrollTop){
					moveTo(getFirstVisible(), false);
				}else{
					window.setTimeout(function(){ waitForScroll(_scrollItem.scrollTop)}, 200);
				}
			}
			
			if(!visibleOnScreen(_current, {halfScreenCheck: true}))
				window.setTimeout(function(){ waitForScroll(_scrollItem.scrollTop)}, 200);
		}
		
		function getFirstVisible(){
			var scrollTop = _scrollItem.scrollTop;
			var clientHeight = document.documentElement.clientHeight;
			//Is the current element still in view?
			if(visibleOnScreen(_current, {halfScreenCheck: true}))
				return;
			var nearestTopLevels = binarySearch(topLevelComments, scrollTop);
			var left = nearestTopLevels[0], right = nearestTopLevels[1];
			
			try{
				var children = Array.prototype.filter.call(left.querySelectorAll('.thing'), function(e){ return isVisible(e.parentNode) } );
				children.shift(left);
				children.push(right);
				var nearestChildren = binarySearch(children, scrollTop);
				
				return visibleOnScreen(nearestChild[0]) ? nearestChild[0] : nearestChild[1];
			}catch(e){
				//No clue what to do here.
				//In fact no clue how it got here
				return _current;
			}
		}
		
		function expandLevel(){
			var c = _current.querySelector('.child');
			var collapsed = _current.querySelector('.collapsed');
			var moreComments = _current.querySelector('.entry').querySelector('.morecomments');
			if(!!moreComments){
				previousComment();
				click(moreComments.firstChild);
			}
			else if(c && (c.children.length > 0)){
				c.firstChild.style.display = '';
				if(isVisible(collapsed)){
					//Need to figure out how to do immediate descendant selector when you're already there
					//Could be re-written as 'self > .midcol, self > .entry > .noncollapsed'
					show([c, _current.querySelector('.midcol'), _current.querySelector('.noncollapsed')]);
					hide(_current.querySelector('.collapsed'));
				}else
					moveTo(c.firstChild.firstChild);
			}else if(isVisible(collapsed)){
				show([_current.querySelector('.midcol'), _current.querySelector('.noncollapsed')]);
				hide(_current.querySelector('.collapsed'));
			}
		}
		
		function collapseLevel(){
			var p = parentThing(_current, root);
			if(p){
				hide(p.querySelector('.child > .sitetable'));
				moveTo(p);
			}
		}
		
		function previousComment(){
			var prev;
			if(prev = previousThing(_current)){
				moveTo(deepestVisibleChild(prev));
			}else{
				var p = parentThing(_current, root);
				if(p)
					moveTo(p);
			}
		}
		
		function nextComment(){
			var n;
			if(n = childThing(_current)){
				moveTo(n);
			}else if(n = nextThing(_current)){
				moveTo(n);
			}else if(n = nextParentThing(_current)){
				moveTo(n);
			}
		}
		
		function hasVisibleChildren(elm){
			var c = elm.querySelector('.child');
			var cThing = c ? c.querySelector('.thing') : null;
			return (c && cThing && isVisible(cThing));
		}
		
		function upvote(){
			click(_current.querySelector('.arrow.up, .arrow.upmod'));
		}
			
		function downvote(){
			click(_current.querySelector('.arrow.down, .arrow.downmod'));
		}
		
		function reply(){
			var li = _current.querySelector('ul.buttons li');
			while(li){
				//UL -> LI -> A -> nodeText
				var link = li.querySelector('a');
				if(link && link.textContent == 'reply'){
					click(link);
					break;
				}
				li = li.nextSibling;
			}
		}
		
		
		
		function nextParentThing(p){
			do{
				if(p = parentThing(p, root)){
					var n = nextThing(p);
					if(n)
						return n;
				}
			}while(p);
		}
		
		function deepestVisibleChild(el){
			var c = el;
			while(true){
				if(hasVisibleChildren(c)){
					c = previousThing(c.querySelector('.sitetable').lastChild);
				}else
					return c;
			}
		}
		
		function moveTo(elm, doScroll){
			if(!elm)
				return;
			if(_current)
				removeFocus(_current);
			_current = elm;
			focusOn(elm);
			
			if(doScroll !== false){
				_scroller.scrollTo(elm, { 
						scrollBegin: function(){
							document.removeEventListener('scroll', trackScroll, false);
						},
						scrollEnd: function(){
							document.addEventListener('scroll', trackScroll, false);
						}
				});
			}else{
				_scroller.stopScroll();
			}
		}
		
		/** Utility functions **/
		function visibleOnScreen(e, options){
			options = options || {halfScreenCheck: false};
			var scrollTop = _scrollItem.scrollTop;
			var clientHeight = document.documentElement.clientHeight;
			
			var result = e && (e.offsetTop >= scrollTop) && ((e.offsetTop + e.offsetHeight) < (scrollTop + clientHeight));
			if(options.halfScreenCheck){
				return result && e && (e.offsetTop <= (scrollTop + (clientHeight / 2)));
			}
			return result;
		}
		
		
		
		return {
			init: function(){
				_init();
			}
		};
	};
	
	
	var PageKeyTraverser = function(root){
		var _mappings = {};
		var _current;
		
		function _init(){
			_scrollItem = getScrollItem();
			_scroller = new Scroller();

			for(var k in PageKeyMappings){
				var key = PageKeyMappings[k];
				_mappings[KeyParser.parseKeyMap(key)] = eval("(function(){ return " + k + "})()");
			}
			start();
			document.addEventListener('keydown', keyTraverser, false);
			if(_scrollItem.scrollTop > 0)
				moveTo(getFirstVisible(), false);
			else
				moveTo(root.querySelector('.thing'), false);
		}
		
		function toggleKeyTraversal(){
			if(_enabled){
				stop();
			}else{
				start();
				moveTo(getFirstVisible());
			}
		}
		
		function start(startOption){
			_enabled = true;
			document.addEventListener('scroll', trackScroll, false);
			setupPage();
		}
		
		function stop(){
			_enabled = false;
			document.removeEventListener('scroll', trackScroll, false);
			if(_current)
				removeFocus(_current);

			var elements = root.querySelectorAll('.thing');
			for(var i = 0; i < elements.length; i++){
				var element = elements[i];
				var listItem = element.querySelector('ul.buttons > li.meow');
				if(listItem){
					listItem.parentNode.removeChild(listItem);
					lisItem = null;
				}
					element.parentNode.style.display = "";
			}
			if(_current)
				_scrollItem.scrollTop = _current.offsetTop - 100;
			_current = null;
			_scroller.stopScroll();
		}
		
		function setupPage(){
			var elements = root.querySelectorAll('.thing');
			for(var i = 0; i < elements.length; i++){
				var element = elements[i];
				var listItem = document.createElement('li');
				listItem.className = 'meow';
				var link = document.createElement('a');
				link.href = "javascript:void(0);";
				link.innerHTML = 'here';
				link.addEventListener('click', function(e){
					return function(){
						moveTo(e, false);
					};
				}(element), false);
				listItem.appendChild(link);
				element.querySelector('ul.buttons').appendChild(listItem);
			}
		}
		
		function keyTraverser(keyEvent){
			if(keyEvent.altKey)
				return;
			var keyValue = KeyParser.parseKeyEvent(keyEvent);
			if(_mappings[keyValue]){
				if(!_enabled && (_mappings[keyValue] !== toggleKeyTraversal))
					return;
				_mappings[keyValue]();
				keyEvent.preventDefault();
			}
		}
		
		function trackScroll(mouseEvent){
			//We want to avoid calculating offsets every time. So we'll simply wait until the scroll stabilizes by checking
			//if the last scrolled offset is the current page scroll offset
			function waitForScroll(offsetY){
				if(offsetY == _scrollItem.scrollTop){
					moveTo(getFirstVisible(), false);
				}else{
					window.setTimeout(function(){ waitForScroll(_scrollItem.scrollTop)}, 200);
				}
			}
			
			if(!visibleOnScreen(_current, {halfScreenCheck: true}))
				window.setTimeout(function(){ waitForScroll(_scrollItem.scrollTop)}, 200);
		}
		
		function getFirstVisible(){
			var scrollTop = _scrollItem.scrollTop;
			var clientHeight = document.documentElement.clientHeight;
			//Is the current element still in view?
			if(visibleOnScreen(_current, {halfScreenCheck: true}))
				return;
			var nearestTopLevels = binarySearch(root.querySelectorAll('.thing'), scrollTop);
			var left = nearestTopLevels[0], right = nearestTopLevels[1];
			return visibleOnScreen(left) ? left : right;
		}
		
		function moveTo(elm, doScroll){
			if(!elm)
				return;
			if(_current)
				removeFocus(_current);
			_current = elm;
			focusOn(elm);
			
			if(doScroll !== false){
				_scroller.scrollTo(elm, { 
						scrollBegin: function(){
							document.removeEventListener('scroll', trackScroll, false);
						},
						scrollEnd: function(){
							document.addEventListener('scroll', trackScroll, false);
						}
				});
			}else{
				_scroller.stopScroll();
			}
		}
		
		
		/** Utility functions **/
		function visibleOnScreen(e, options){
			options = options || {halfScreenCheck: false};
			var scrollTop = _scrollItem.scrollTop;
			var clientHeight = document.documentElement.clientHeight;
			
			var result = e && (e.offsetTop >= scrollTop) && ((e.offsetTop + e.offsetHeight) < (scrollTop + clientHeight));
			if(options.halfScreenCheck){
				return result && e && (e.offsetTop <= (scrollTop + (clientHeight / 2)));
			}
			return result;
		}
		function previousPost() {
			var item = previousThing(_current);
			if(item){
				moveTo(item);
			}	
		}
		function nextPost(){
			var item = nextThing(_current);
			if(item){
				moveTo(item);
			}
		}
		
		function previousPage(){
			var nextprev = document.querySelector('p.nextprev');
			if(nextprev){
				//The next, prev rels are broken. For instance, the first page next has a rel of "prev".
				var prev = nextprev.querySelector('a[href*="before"]');
				if(prev)
					followLink(prev);
			}
		}
		
		function nextPage(){
			var nextprev = document.querySelector('p.nextprev');
			if(nextprev){
				var next = nextprev.querySelector('a[href*="after"]');
				if(next)
					followLink(next);
			}
		}
			
		function upvote(){
			click(_current.querySelector('.arrow.up, .arrow.upmod'));
		}
			
		function downvote(){
			click(_current.querySelector('.arrow.down, .arrow.downmod'));
		}
		
		function comments() {
			followLink(_current.querySelector('a.comments'));
		}
		
		function gotoPost() {
			followLink(_current.querySelector('a.title'));
		}
	    
		function gotoPostAndComments () {
			followLink(_current.querySelector('a.title'), true);
			comments();
		}
		
		function expandPreview(){
			var expando = _current.querySelector('div.expando-button');
			if(expando){
				click(expando);
				//Might need to scroll the screen around a bit to make sure the expando fits in
				scroller.scrollTo(_current.querySelector('div.expando'));
			}
		}
			
		return {
			init: function(){
				_init();
			}
		};
	}
	
	var Scroller = function(){
		var scrollAmt = 20, scrollSpeed = 5, topOffset = 200;
		var _scrollCallback;
		var _scrollItem = getScrollItem();
		var _onScrollComplete;
		
		function scrollDown(dim){
			var scrollTop = _scrollItem.scrollTop;
			var scrollHeight = _scrollItem.scrollHeight;
			var clientHeight = dim.clientHeight;
			var elmOffset = dim.elmOffset;
			
			if((scrollTop < elmOffset) && (scrollTop + clientHeight) < scrollHeight){
				if((scrollTop + scrollAmt) > elmOffset){
					_scrollItem.scrollTop += elmOffset - scrollTop;
				}else{
					_scrollItem.scrollTop += scrollAmt;
				}
				
				_scrollCallback = setTimeout(function(){ scrollDown(dim) } , scrollSpeed);
			}else{
				stopScroll();
				scrollComplete();
			}
		}
			
		function scrollUp(dim){
			var elmOffset = dim.elmOffset;
			
			if(_scrollItem.scrollTop > elmOffset){
				_scrollItem.scrollTop -= scrollAmt;
				_scrollCallback = setTimeout(function(){ scrollUp(dim) }, scrollSpeed);
			}else{
				stopScroll();
				scrollComplete();
			}
		}
					
		function scrollComplete(){
			if(_onScrollComplete){
				_onScrollComplete();
				_onScrollComplete = null;
			}
		}
		
		function stopScroll(){
			if(!!_scrollCallback){
				clearTimeout(_scrollCallback);
				_scrollCallback = null;
			}
		}
		
		function scrollTo(elm, options){
			options = options || { scrollBegin: function(){} , scrollEnd: function(){} };
			
			stopScroll();
			options.scrollBegin();
			_onScrollComplete = options.scrollEnd;
			
			var scrollTop = _scrollItem.scrollTop;
			var clientHeight = document.documentElement.clientHeight;
			var elmOffset = elm.offsetTop - topOffset;
			
			if(clientHeight <= elm.offsetHeight) 
				elmOffset = elm.offsetTop;
			else if(elmOffset < 0)
				elmOffset = 0;
			
			var dim = {clientHeight: clientHeight, elmOffset: elmOffset};
			
			
			if(Math.abs(scrollTop - elmOffset) > scrollAmt){
				if(scrollTop < elmOffset){
					scrollDown(dim);
				}
				else{
					scrollUp(dim);
				}
			}
		}
		
		return {
			stopScroll: stopScroll, scrollTo: scrollTo
		};
	}
	
	function getScrollItem(){
		//scrollTop is get and set via document.body in Chrome. 
		if(navigator.userAgent.indexOf('Chrome') != -1){
			//Do Chrome things
			return document.body;
		}else{
			return document.documentElement;
		}
	}
	
	function binarySearch(elements, value){
		var left = 0, right = elements.length - 1;
		
		while((right - left) > 1){
			var middle = Math.floor((right + left) / 2);
			var middleElement = elements[middle];
			if(middleElement.offsetTop > value)
				right = middle;
			else if(middleElement.offsetTop < value)
				left = middle;
			else
				break;
		}
		return [elements[left], elements[right]];
	}
	
	function previousThing(elm){
		elm = elm.previousSibling;
		while(elm){
			if(isThing(elm) && isVisible(elm))
				return elm;
			elm = elm.previousSibling;
		}
		return null;
	}
	
	function nextThing(elm){
		elm = elm.nextSibling;
		while(elm){
			if(isThing(elm) && isVisible(elm))
				return elm;
			elm = elm.nextSibling;
		}
		return null;
	}
	
	function parentThing(elm, root){
		while(elm && (elm != root)){
			elm = elm.parentNode;
			if(isThing(elm))
				return elm;
		}
		return null;
	}
	
	function isThing(elm){
		//Hopefully they will never name another item a thing
		//indexOf works much quicker than regex.
		return (elm.className.indexOf('thing') != -1);
	}
		
	function childThing(elm){
		var c = elm.querySelector('.thing');
		while(c){
			if(isVisible(c))
				return c;
			c = nextThing(c);
		}
		return null;
	}
	
	function show(a){
		if(a.length){
			for(var i = 0; i < a.length; i++)
				a[i].style.display = '';
		}else
			a.style.display = '';
	}

	function hide(a){
		if(a.length){
			for(var i = 0; i < a.length; i++)
				a[i].style.display = 'none';
		}else
			a.style.display = 'none';
	}
	
	function isVisible(elm){
		//The offset seems to be a much better way to identify elements that are hidden
		return (elm.offsetTop != 0) && (elm.style.display != 'none');
	}

	function addStyle(elm, style){
		for(var k in style){
			elm.style[k] = style[k];
		}
	}

	function click(el){
		var evt = document.createEvent("MouseEvents");
		evt.initMouseEvent("click", true, true, window, 0, 0, 0, 0, 0, false, false, false, false, 0, null);
		el.dispatchEvent(evt);
	}
	
	function followLink(link, openInNewWindow){
		if((link.target == '_blank') || openInNewWindow){
			GM_openInTab(link.href);
		}
		(unsafeWindow || window).location.href = link.href;
	}
	
	function focusOn(elm){
		if(elm)
			addStyle(elm.querySelector('.entry'), {'backgroundColor': '#eaeafa', 'border': '1px solid #cacada'});
	}
	
	function removeFocus(elm){
		if(elm)
			addStyle(elm.querySelector('.entry'), {'backgroundColor': '#ffffff', 'border': 'none'});
	}
	
	var commentArea = document.querySelector('div.commentarea');
	var siteList;
	if(commentArea){
		new CommentKeyTraverser(commentArea).init();
	}else if(siteList = document.querySelector('#siteTable > .thing')){
		new PageKeyTraverser(document.getElementById('siteTable')).init();
	}
})();