Scroll like Opera

An userscript to provide Opera(old) like scrolling behavior.

// ==UserScript==
// @name        Scroll like Opera
// @description	An userscript to provide Opera(old) like scrolling behavior.
// @namespace   eight04.blogspot.com
// @include     http*
// @version     2.1.0
// @require		https://greasyfork.org/scripts/7212-gm-config-eight-s-version/code/GM_config%20(eight's%20version).js?version=29833
// @require		https://greasyfork.org/scripts/7108-bezier-easing/code/bezier-easing.js?version=29098
// @grant       GM_setValue
// @grant		GM_getValue
// @grant       GM_registerMenuCommand
// ==/UserScript==

(function(){

	"use strict";

	var config;

	GM_config.init(
		"Scroll like Opera",
		{
			useWhenOnScrollbar: {
				label: "Scroll horizontally if cursor hover on horizontal scrollbar.",
				type: "checkbox",
				default: true
			},
			useWhenOneScrollbar: {
				label: "Scroll horizontally if there is only horizontal scrollbar presented.",
				type: "checkbox",
				default: true
			},
			useAlways: {
				label: "Always use script's scrolling handler. Enable this if you want to use the script's smooth scrolling on chrome.",
				type: "checkbox",
				default: false
			},
			scrollDelay: {
				label: "Smooth scrolling delay.",
				type: "text",
				default: 400
			},
			scrollOffset: {
				label: "Scrolling offset.",
				type: "text",
				default: 120
			},
			continueScrollingTimeout: {
				label: "Scrolled target changing delay.",
				type: "number",
				default: 400
			}
		}
	);

	config = GM_config.get();

	GM_registerMenuCommand("Scroll like Opera - Configure", function(){
		GM_config.open();
	});

	GM_config.onclose = function(){
		config = GM_config.get();
	};

	/**
		Cache current scrolling target
	*/
	var cache = {
		timeout: null,
		target: null,
		cache: function(element) {
			cache.target = element;
			cache.delay();
		},
		reset: function() {
			clearTimeout(cache.timeout);
			cache.timeout = null;
			cache.target = null;
		},
		delay: function() {
			clearTimeout(cache.timeout);
			cache.timeout = setTimeout(cache.reset, config.continueScrollingTimeout);
		}
	};

	/**
		Register event
	*/
	window.addEventListener("wheel", function(e){
		var q;

		if (cache.target) {
			q = getScrollInfo(cache.target, e, true);

			// Scrolled to edge
			if (!q) {
				e.preventDefault();
				return;
			}

			cache.delay();
		} else {
			q = getScrollInfo(e.target, e);

			// Can't find scrollable element
			if (!q) {
				return;
			}

			cache.cache(q.element);
		}

		if (q.use || config.useAlways) {
			e.preventDefault();
			scroll(q.element, q.offsetX, q.offsetY);
		}
	});

	window.addEventListener("mousemove", function(){
		cache.reset();
	});

	/**
		Main logic
	*/
	function getInfo(element, e) {
		var rect, css;

		if (element == document.documentElement) {
			return {
				element: element,
				onScrollbarX: e.clientY >= element.clientHeight && e.clientY <= window.innerHeight,
				scrollableX: element.scrollWidth > element.clientWidth,
				scrollableY: element.scrollHeight > element.clientHeight
			};
		} else if (element == document.body) {
			return {
				element: element,
				onScrollbarX: false,
				scrollableX: false,
				scrollableY: false
			};
		} else {
			rect = element.getBoundingClientRect();
			css = getCss(element);

			return {
				element: element,
				onScrollbarX: element.clientHeight && e.clientY >= rect.top + css.borderTop + element.clientHeight && e.clientY <= rect.bottom - css.borderBottom,
				scrollableX: element.clientWidth && element.scrollWidth > element.clientWidth && css.overflowX != "visible" && css.overflowX != "hidden",
				scrollableY: element.clientHeight && element.scrollHeight > element.clientHeight && css.overflowY != "visible" && css.overflowY != "hidden"
			};
		}
	}

	function getScrollInfo(element, e, noParent) {
		var q;

		// Get scrollable parent
		while (element) {

			q = getInfo(element, e);

			if (e.deltaY && (q.onScrollbarX || useHorizontalScroll(q)) && scrollable(element, e.deltaY, 0)) {
				// Horizontal scroll
				q.offsetX = getOffset(e.deltaY);
				q.offsetY = 0;
				q.use = true;
				return q;
			}

			if ((e.deltaX && q.scrollableX || e.deltaY && q.scrollableY) && scrollable(element, e.deltaX, e.deltaY)) {
				q.offsetX = getOffset(e.deltaX);
				q.offsetY = getOffset(e.deltaY);
				return q;
			}

			if (noParent) {
				return null;
			}

			element = element.parentNode;
			if (element == document) {
				return null;
			}
		}

		return null;
	}

	/**
		Scroll function. Should I put them into seperate library?
		Thanks to https://github.com/galambalazs/smoothscroll
	*/
	var animate = null;
	var que = [];
	function scroll(element, x, y) {
		var elapsed = config.scrollDelay;

		que.push({
			offsetX: x,
			offsetY: y,
			lastX: 0,
			lastY: 0,
			element: element,
			timeStart: null
		});

		if (animate != null) {
			return;
		}

		function animation(timestamp){
			var i, j, len, q, time, offsetX, offsetY, process, swap;

			swap = [];

			for (i = 0, j = 0, len = que.length; i < len; i++) {
				q = que[i];
				if (q.timeStart == null) {
					q.timeStart = timestamp;
				}
				if (timestamp - q.timeStart >= elapsed || !scrollable(q.element, q.offsetX, q.offsetY)) {
					scrollBy(q.element, q.offsetX - q.lastX, q.offsetY - q.lastY);
				} else {
					time = (timestamp - q.timeStart) / elapsed;
					process = ease(time);
					offsetX = Math.floor(q.offsetX * process);
					offsetY = Math.floor(q.offsetY * process);

					scrollBy(q.element, offsetX - q.lastX, offsetY - q.lastY);

					q.lastX = offsetX;
					q.lastY = offsetY;
					swap[j++] = q;
				}
			}

			que = swap;
			if (!que.length) {
				animate = null;
				return;
			}

			animate = requestAnimationFrame(animation);
		}
		animate = requestAnimationFrame(animation);
	}

	/**
		Helpers
	*/
	function useHorizontalScroll(q) {
		return config.useWhenOneScrollbar && q.scrollableX && !q.scrollableY;
	}

	function getOffset(delta) {
		var direction = 0;
		if (delta > 0) {
			direction = 1;
		} else if (delta < 0) {
			direction = -1;
		}
		return direction * config.scrollOffset;
	}

	function ease(t){
		return BezierEasing.css.ease(t);
	}

	function getCss(element){
		var css = getComputedStyle(element);

		return {
			borderTop: parseInt(css.getPropertyValue("border-top-width"), 10),
			borderRight: parseInt(css.getPropertyValue("border-right-width"), 10),
			borderBottom: parseInt(css.getPropertyValue("border-bottom-width"), 10),
			borderLeft: parseInt(css.getPropertyValue("border-left-width"), 10),
			overflowX: css.getPropertyValue("overflow-x"),
			overflowY: css.getPropertyValue("overflow-y")
		};
	}

	function scrollable(element, offsetX, offsetY) {
		var top, left;
		if (element == document.documentElement) {
			top = window.scrollY;
			left = window.scrollX;
		} else {
			top = element.scrollTop;
			left = element.scrollLeft;
		}

		if (top == 0 && offsetY < 0) {
			return false;
		}
		if (left == 0 && offsetX < 0) {
			return false;
		}
		if (top + element.clientHeight >= element.scrollHeight && offsetY > 0) {
			return false;
		}
		if (left + element.clientWidth >= element.scrollWidth && offsetX > 0) {
			return false;
		}
		return true;
	}

	function scrollBy(element, x, y) {
		if (element != document.documentElement) {
			element.scrollLeft += x;
			element.scrollTop += y;
		} else {
			window.scrollBy(x, y);
		}
	}

})();