joeytwiddle / Auto scroll keys

// ==UserScript==
// @name           Auto scroll keys
// @namespace      http://userscripts.org/users/44573
// @version        1.1.3
// @license        MIT
// @description    Read articles hands-free!  Pres Ctrl-Down or Opt-Down to start slowly scrolling the page.  Press again to adjust speed.  Escape or Space to stop.
// @grant          none
// @include        *
// ==/UserScript==

// This script is a derivative of http://userscripts-mirror.org/scripts/show/70300 by PaC1250

// Options:

var initialSpeed = 10;     // Pixels per second

var maxPerSecond = 60;     // Target FPS

var resetThreshold = 10;   // If the user scrolls the page manually by more pixels than this value, then we will start scrolling from the new position.

// When zoomed in, a page pixel will be larger than a screen pixel.  Enabling this will shift by fractions of a page pixel, rather than whole page pixels, so that the text won't make multi-pixel jumps.
var attemptSubPixelScrolling = false;

// But at default zoom, sub-pixel scrolling may look a little bit jittery, so this option will enable sub-pixel zooming only when it's needed.
// (Although personally in Chrome I found sub-pixel zooming looked preferable even at zoom 100%.)
// This overwrites the value of attemptSubPixelScrolling when scrolling begins.
var attemptSubPixelScrollingOnlyIfZoomedIn = true;

// The user may perform normal scrolling actions during auto-scroll (e.g. by pressing Up or PageUp or using the scroll bar).  We will detect and ackowledge these (update realy) if we see a difference of more than this many pixels.
// If the threshold is set too low, the script's own scrolling will trigger it, especially on slower machines or under heavy load.
// But if set too high, the threshold may fail to fire if, when the user presses Up to scroll back a little, the browser performs that with a smooth scroll of lots of small movements, rather than one larger jump.

// BUG: When zoomed in, jumping by a whole (unzoomed) pixel it too coarse, because we see the text move by more than one screen pixel.  But we cannot control that: we give the browser a float for scrolling but it rounds it to an int!
// A possible workaround would be to perform some scrolling (perhaps just the remainder part before rounding) as a transform on the page.  This might not work in all browsers.

// 2012/10/09  Now runs at 60fps or whatever the machine can handle

// Constants:

var DOM_VK_DOWN   = 40;
var DOM_VK_UP     = 38;
var DOM_VK_ESCAPE = 27;
var DOM_VK_SPACE  = 32;
var DOM_VK_ENTER  = 13;

// Runtime:

var u44573_go = false;
var scrollSpeed = 0;

var lastTime;
var realx, realy;   // We store scroll position as floats; basing scrolling on a float is smoother than the browser's int-rounded scrollTop.  But we may need to keep realy in sync with scrollTop, if the user changes the later during auto-scroll.

window.addEventListener('keydown', u44573_handler, true);

var transformBeforeScrolling;

function u44573_handler(e) {
	var change = 0.60;   // Probably could be lowered a bit if we make scrollSpeed truly analogue.
	var upDelta, downDelta; // The acceleration of each key

	if (scrollSpeed === 0 || !u44573_go) {
		upDelta = initialSpeed;
		downDelta = initialSpeed;
	} else {
		if (Math.abs(scrollSpeed) < 0.1) {
			scrollSpeed = 0.1 * sgn(scrollSpeed);
		}
		downDelta = Math.abs(scrollSpeed)*change;
		upDelta = Math.abs(scrollSpeed)*change/(1+change);
		if (scrollSpeed < 0) {
			var tmpDelta = downDelta;
			downDelta = upDelta;
			upDelta = tmpDelta;
		}
	}

	// Scroll downwards with CTRL-Down_Arrow or Opt-Down_Arrow on Mac
	if((e.altKey || e.ctrlKey) && e.keyCode == DOM_VK_DOWN) {
		scrollSpeed += downDelta;
		e.preventDefault();
	}
	// Scroll upwards with CTRL-Up_Arrow or Opt-Up_Arrow on Mac
	if((e.altKey || e.ctrlKey) && e.keyCode == DOM_VK_UP) {
		scrollSpeed -= upDelta;
		e.preventDefault();
	}

	if(!u44573_go && scrollSpeed != 0) {
		startScroller();
	}

	// Stop (ESCAPE or ENTER or SPACE)
	if (e.keyCode == DOM_VK_ESCAPE || e.keyCode == DOM_VK_ENTER || e.keyCode == DOM_VK_SPACE) {
		if (u44573_go) {
			u44573_go = false;
			scrollSpeed = 0;

			if (attemptSubPixelScrolling) {
				document.body.style.transform = transformBeforeScrolling;
			}

			// Do not pass keydown event to page:
			e.preventDefault(); // Most browsers
			return false; // IE
		}
	}
}

function sgn(x) {
	return ( x>0 ? +1 : x<0 ? -1 : 0 );
}

var abs = Math.abs;

function startScroller() {
	u44573_go = true;
	var s = u44573_getScrollPosition();
	realx = s[0];
	realy = s[1];
	lastTime = new Date().getTime();
	transformBeforeScrolling = document.body.style.transform;

	if (attemptSubPixelScrollingOnlyIfZoomedIn) {
		// This technique find the zoom level in Chrome
		// For other browser, see https://github.com/tombigel/detect-zoom or http://stackoverflow.com/questions/1713771
		var screenCssPixelRatio = window.outerWidth / window.innerWidth;
		console.log("Detected zoom: "+screenCssPixelRatio);
		var isZoomedIn = (screenCssPixelRatio >= 1.05);
		attemptSubPixelScrolling = isZoomedIn;
	}

	u44573_goScroll();
}

function queueNextFrame(callback, duration) {
	if (typeof window.requestAnimationFrame === 'function') {
		window.requestAnimationFrame(callback);
	} else {
		setTimeout(callback, duration);
	}
}

function u44573_goScroll() {

	if (u44573_go) {

		// Check if the user has scrolled the page with a key since we last scrolled.
		// If so, update our realx,realy.
		// BUG: Argh the check isn't working in Firefox 90% of the time!
		//      Hold down the key to beat those odds.
		var s = u44573_getScrollPosition();
		if ( abs(s[0]-realx) > resetThreshold || abs(s[1]-realy) > resetThreshold ) {
			realx = s[0];
			realy = s[1];
		}

		var timeNow = new Date().getTime();
		var elapsed = timeNow - lastTime;
		var jumpPixels = abs(scrollSpeed) * elapsed/1000;
		var timeToNext = 1000/maxPerSecond;

		// Rather than running at a fixed FPS, at slow speeds the approaches below would delay the next callback until it was time to move to the next pixel.  This was more efficient on slow machines.
		/*
		// The browser can only jump a whole number of pixels, and it rounds down.
		// We had to do the following anyway for jumpPixels<1 but by doing it for
		// small numbers (<5) we workaround the analogue/digital bug.  (5*1.2=6)
		if (jumpPixels < 3) {
			timeToNext /= jumpPixels;
			// jumpPixels /= jumpPixels;
			jumpPixels = 1;
		}
		*/
		/*
		var timeToNext = 1000/abs(scrollSpeed);
		if (timeToNext < 1000/maxPerSecond) {
			jumpPixels = abs(scrollSpeed)/maxPerSecond;
			timeToNext = 1000/maxPerSecond;
		} else {
			jumpPixels = 1;
		}
		*/

		realy += jumpPixels*sgn(scrollSpeed);

		//var inty = Math.round(realy);
		var inty = Math.floor(realy);

		if (attemptSubPixelScrolling) {
			var remaindery = realy - inty;
			var transform = transformBeforeScrolling + " translate(0px, "+(-remaindery)+"px)";
			document.body.style.transform = transform;
		}

		window.scroll(realx, inty); // Leave it to browser to round real values to ints

		lastTime = timeNow;

		if (scrollSpeed == 0) {
			u44573_go = false;
		} else {
			queueNextFrame(u44573_goScroll, timeToNext);
		}

	}

}

function u44573_getScrollPosition() {
	return Array((document.documentElement && document.documentElement.scrollLeft) || window.pageXOffset || self.pageXOffset || document.body.scrollLeft,(document.documentElement && document.documentElement.scrollTop) || window.pageYOffset || self.pageYOffset || document.body.scrollTop);
}