Scroll Everywhere

Scroll entire page smoothly with long left-click and drag.

// ==UserScript==
// @name            Scroll Everywhere
// @description     Scroll entire page smoothly with long left-click and drag.
// @author          tumpio
// @oujs:author     tumpio
// @contributor     joeytwiddle
// @namespace       tumpio@sci.fi
// @homepageURL     https://openuserjs.org/scripts/tumpio/Scroll_Everywhere
// @supportURL      https://github.com/tumpio/gmscripts
// @icon            https://raw.githubusercontent.com/tumpio/gmscripts/master/Scroll_Everywhere/large.png
// @include         *
// @grant           GM_addStyle
// @run-at          document-body
// @version         0.3p
// @license         MIT
// ==/UserScript==

// Does not work on Steam: https://steamcommunity.com/discussions/forum/10/458604254435648435/?ctp=9

// This is a version of tumpio's script which defaults to left-click drag after a long press, and will do a relative scroll of the entire page.  I find this more intuitive.

/* jshint multistr: true, strict: false, browser: true, devel: true */
/* global escape: true,GM_getValue: true,GM_setValue: true,GM_addStyle: true,GM_xmlhttpRequest: true */

/* eslint-disable eqeqeq */
/* eslint-disable curly */
/* eslint-disable no-redeclare */

// TODO: add slow scroll start mode
// FIXME: Linux/mac context menu on mousedown, probably needs browser level
// FUTURE: Options dialog

// ISSUES:
//   The fix for scrollbars works, but we need a similar for for other widgets, for example native dropdown menu widgets.

var mouseBtn, reverse, stopOnSecondClick, verticalScroll, startAnimDelay, cursorStyle, down,
    scrollevents, scrollBarWidth, cursorMask, isWin, fScrollX, fScrollY, fScroll, slowScrollStart;

var middleIsStart, startX, startY, startScrollTop, startScrollLeft, lastScrollHeight;

var relativeScrolling, lastX, lastY, scaleX, scaleY, power, offsetMiddle;

var lastMiddleClickTime;

var startAfterLongPress, longPressTimer, eventBeforeLongPress, longPressStylesAdded;

var scrollStartTime, scrollStopTime;

var elementToScroll;

// NOTE: Do not run on iframes
if (window.top === window.self) {
    // USER SETTINGS
    mouseBtn = 1; // 1:left, 2:middle, 3:right mouse button
    startAfterLongPress = true; // Only start scrolling after a long click
    reverse = true; // reversed scroll direction
    stopOnSecondClick = false; // keep scrolling until the left mouse button clicked
    verticalScroll = false; // vertical scrolling
    slowScrollStart = false; // slow scroll start on begin
    startAnimDelay = 150; // slow scroll start mode animation delay
    cursorStyle = "grab"; // cursor style on scroll
    middleIsStart = true; // don't jump when the mouse starts moving
    relativeScrolling = false; // scroll the page relative to where we are now
    scaleX = 3; // how fast to scroll with relative scrolling
    scaleY = 3;
    power = 3; // when moving the mouse faster, how quickly should it speed up?
    // END

    fScroll = ((reverse) ? fRevPos : fPos);
    fScrollX = ((verticalScroll) ? fScroll : noScrollX);
    fScrollY = fScroll;
    down = false;
    scrollevents = 0;
    scrollBarWidth = 2 * getScrollBarWidth();
    cursorMask = document.createElement('div');
    isWin = window.navigator.appVersion.indexOf("Win") >= 0;
    if (cursorStyle === "grab")
        cursorStyle = "-webkit-grabbing; cursor: -moz-grabbing";
    cursorMask.id = "SE_cursorMask_cursor";
    cursorMask.setAttribute("style", "position: fixed; width: 100%; height: 100%; zindex: 5000; top: 0px; left: 0px; cursor: " + cursorStyle + "; background: none; display: none;");
    document.body.appendChild(cursorMask);

    window.addEventListener("mousedown", handleMouseDown, false);
    window.addEventListener("mouseup", handleMouseUp, false);
    window.addEventListener("click", handleClick, true);
    window.addEventListener('paste', handlePaste, true);
}

function handleMouseDown(e) {
    // From: https://stackoverflow.com/questions/10045423/determine-whether-user-clicking-scrollbar-or-content-onclick-for-native-scroll
    var wasClickOnScrollbar = e.target.clientWidth > 0 && e.offsetX > e.target.clientWidth || e.target.clientHeight > 0 && e.offsetY > e.target.clientHeight;
    if (wasClickOnScrollbar) {
        //console.log('Ignoring click on scrollbar:', e, `${e.offsetX} > ${e.target.clientWidth} || ${e.offsetY} > ${e.target.clientHeight}`);
        return;
    }
    if (e.which == mouseBtn) {
        if (startAfterLongPress) {
            startLongPress(e);
        } else {
            if (!down) {
                start(e);
            } else {
                stop();
            }
        }
    }
}

function handleMouseUp(e) {
    if (e.which == 2) {
        lastMiddleClickTime = Date.now();
    }
    if (startAfterLongPress) {
        cancelLongPress();
    }
}

function handleClick(e) {
    // If we were just in scrolling mode, then we don't want other listeners to see this click event
    var justStoppedScrolling = Date.now() <= scrollStopTime + 20;
    // But if we went in and out of scrolling mode in a short time, then this was actually a click
    var wasShortClick = !startAfterLongPress && scrollStopTime - scrollStartTime < 200;
    if (justStoppedScrolling && !wasShortClick) {
        //console.info("MUTING click event");
        e.preventDefault();
        e.stopPropagation();
    }
}

function handlePaste(e) {
    var timeSinceLastMiddleClick = Date.now() - lastMiddleClickTime;
    //console.log("Pasting (" + timeSinceLastMiddleClick + "ms):", (event.clipboardData || window.clipboardData).getData('text'));

    // If you use middle button for scrolling on Linux, then you might be sending a paste event every time you use this scroller.
    // Depending on the contents of your clipboard, that could be a privacy leak!
    // Therefore we disable paste events if they come after a middle click (if the user uses middle click for scrolling).
    //
    // Note this solution is still not entirely safe.  There could be an event listener registered before us, which would see the paste.
    // Another option is to disable middle-click but this also isn't trivial to do universally: https://askubuntu.com/questions/4507
    //
    // TODO: It would be better to check if this was a middle-click drag (i.e. a scroll).  A plain short middle-click we could interpret as a paste.

    if (mouseBtn == 2 && timeSinceLastMiddleClick < 200) {
        e.preventDefault();
        e.stopPropagation();
        return false;
    }
}

function startLongPress(e) {
    cancelLongPress();
    eventBeforeLongPress = e;
    longPressTimer = setTimeout(longPressDetected, 500);
    window.addEventListener("mousemove", cancelLongPress, false);
}

function longPressDetected() {
    // Cleanup
    cancelLongPress();
    if (mouseBtn == 1) {
        // After a long press with the left mouse button, the browser will start selecting text, which will get messy when we scroll
        // So we try to cancel that selection
        selectNoText();
    }
    start(eventBeforeLongPress);
    // Give the user a visual indication that scrolling mode has started
    cursorMask.style.display = "";
    // A stronger indication: a ripple effect starting from the mouse location
    // This is especially useful when our pointer change is overriden by the page's CSS
    // Based on: https://css-tricks.com/how-to-recreate-the-ripple-effect-of-material-design-buttons/
    if (!longPressStylesAdded) {
        GM_addStyle(`
            #scroll-anywhere-ripple-animation {
                position: fixed;
                width: 20px;
                height: 20px;
                border-radius: 50%;
                transform: scale(0);
                animation: ripple 600ms ease-out;
                background-color: #aaa8;
                z-index: 999999;
                pointer-events: none;
            }
            @keyframes ripple {
                from {
                    transform: scale(0);
                    opacity: 1;
                }
                to {
                    transform: scale(16);
                    opacity: 0;
                }
            }
        `);
        longPressStylesAdded = true;
    }
    var circleDiv = document.createElement('div');
    circleDiv.id = 'scroll-anywhere-ripple-animation';
    circleDiv.style.left = (eventBeforeLongPress.clientX - 10) + 'px';
    circleDiv.style.top = (eventBeforeLongPress.clientY - 10) + 'px';
    document.body.appendChild(circleDiv);
    setTimeout(() => {
        circleDiv.parentNode.removeChild(circleDiv);
    }, 2000);
}

function cancelLongPress() {
    clearTimeout(longPressTimer);
    window.removeEventListener("mousemove", cancelLongPress);
}

function start(e) {
    down = true;
    elementToScroll = findElementToScroll(e.target);
    //console.log('Will do scrolling on:', elementToScroll, elementToScroll.scrollTop, elementToScroll.scrollHeight, getComputedStyle(elementToScroll).overflow);
    scrollStartTime = Date.now();
    setStartData(e);
    lastX = e.clientX;
    lastY = e.clientY;
    if (!slowScrollStart)
        scroll(e);
    window.addEventListener("mousemove", waitScroll, false);
    if (!stopOnSecondClick)
        window.addEventListener("mouseup", stop, false);
}

function findElementToScroll(elem) {
    if (elem.clientHeight > 0 && elem.scrollHeight > elem.clientHeight) {
        var overflow = getComputedStyle(elem).overflow;
        if (overflow === '' || overflow.match(/(auto|scroll|overlay)/)) {
            //console.log('overflow:', overflow);
            return elem;
        }
    }
    if (!elem.parentNode) {
        // On some sites, documentElement works better than body
        return document.documentElement.scrollHeight > 0 ? document.documentElement : document.body;
    }
    return findElementToScroll(elem.parentNode);
}

function setStartData(e) {
    lastScrollHeight = getScrollHeight();
    startX = e.clientX;
    startY = e.clientY;
    // On some pages, body.scrollTop changes whilst documentElement.scrollTop remains 0.
    // For example: https://docs.kde.org/trunk5/en/kde-workspace/kcontrol/autostart/index.html
    // See: https://stackoverflow.com/questions/19618545
    startScrollTop = elementToScroll.scrollTop || 0;
    startScrollLeft = elementToScroll.scrollLeft || 0;
    if (elementToScroll === document.documentElement || elementToScroll === document.body) {
        startScrollTop = document.documentElement.scrollTop || document.body.scrollTop || 0;
        startScrollLeft = document.documentElement.scrollLeft || document.body.scrollLeft || 0;
    }
}

function waitScroll(e) {
    scrollevents += 1;
    if (scrollevents > 2) {
        cursorMask.style.display = "";
        if (isWin)
            document.oncontextmenu = fFalse;
        window.removeEventListener("mousemove", waitScroll, false);
        window.addEventListener("mousemove", scroll, false);
    }
}

function scroll(e) {
    // If the site has just changed the height of the webpage (e.g. by auto-loading more content)
    // then we must adapt to the new height to avoid jumping.
    if (lastScrollHeight !== getScrollHeight()) {
        setStartData(e);
    }
    //scrollevents += 1;
    if (!stopOnSecondClick && e.buttons === 0) {
        stop();
        return;
    }
    if (relativeScrolling) {
      var diffX = e.clientX - lastX;
      var diffY = e.clientY - lastY;
      var distance = Math.sqrt(diffX * diffX + diffY * diffY);
      var velocity = 1 + distance * power / 100;
      var reverseScale = reverse ? -1 : 1;
      //doScrollTo(window, window.scrollX + diffX * scaleX * velocity * reverseScale, window.scrollY + diffY * scaleY * velocity * reverseScale);
      doScrollTo(elementToScroll, elementToScroll.scrollLeft + diffX * scaleX * velocity * reverseScale, elementToScroll.scrollTop + diffY * scaleY * velocity * reverseScale);
      lastX = e.clientX;
      lastY = e.clientY;
      return;
    }
    var newX = fScrollX(
        window.innerWidth - scrollBarWidth,
        getScrollWidth() - getClientWidth(),
        e.clientX);
    var newY = fScrollY(
        window.innerHeight - scrollBarWidth,
        getScrollHeight() - getClientHeight(),
        e.clientY);
    doScrollTo(elementToScroll, newX, newY);
}

function doScrollTo(elem, x, y) {
    //console.log(`Doing scroll: ${x} ${y}`);
    // For normal HTML elements
    elem.scrollTo(x, y);
    // For React Native elements
    elem.scrollTo({ x: x, y: y, animated: false });
    if (elem === document.documentElement) {
        document.body.scrollTo(x, y);
        document.body.scrollTo({ x: x, y: y, animated: false });
    }
    if (elem === document.body) {
        document.documentElement.scrollTo(x, y);
        document.documentElement.scrollTo({ x: x, y: y, animated: false });
    }
}

function stop() {
    cursorMask.style.display = "none";
    if (isWin)
        document.oncontextmenu = !fFalse;
    down = false;
    scrollStopTime = Date.now();
    scrollevents = 0;
    window.removeEventListener("mouseup", stop, false);
    window.removeEventListener("mousemove", scroll, false);
    window.removeEventListener("mousemove", waitScroll, false);
}

function noScrollX() {
    return elementToScroll.scrollLeft;
}

function fPos(win, doc, pos) {
    if (middleIsStart) {
        if (pos < startY) {
            return startScrollTop * pos / startY;
        } else {
            return startScrollTop + (doc - startScrollTop) * (pos - startY) / (win - startY);
        }
    }
    return doc * (pos / win);
}

function fRevPos(win, doc, pos) {
    if (middleIsStart) {
        if (pos < startY) {
            return startScrollTop + (doc - startScrollTop) * (startY - pos) / startY;
        } else {
            return startScrollTop - startScrollTop * (pos - startY) / (win - startY);
        }
    }
    return doc - fPos(win, doc, pos);
}

function getScrollHeight() {
    return elementToScroll.scrollHeight || 0;
}

function getScrollWidth() {
  return elementToScroll.scrollWidth || 0;
}

function getClientHeight(e) {
    // Sometimes documentElement will return the full scrollHeight, but we want the smaller visible portal that body returns
    if (elementToScroll === document.documentElement || elementToScroll === document.body) {
        return Math.min(document.documentElement.clientHeight, document.body.clientHeight);
    }
    return elementToScroll.clientHeight || 0;
}

function getClientWidth(e) {
    if (elementToScroll === document.documentElement || elementToScroll === document.body) {
        return Math.min(document.documentElement.clientWidth, document.body.clientWidth);
    }
    return elementToScroll.clientWidth || 0;
}

function getScrollBarWidth() {
    var originalOverflow = document.body.style.overflow;
    document.body.style.overflow = 'hidden';
    var width = document.body.clientWidth;
    document.body.style.overflow = 'scroll';
    width -= document.body.clientWidth;
    if (!width) width = document.body.offsetWidth - document.body.clientWidth;

    // Now we set overflow back to how it was
    // But if style === '' then Firefox will sometimes leave the temporary scrollbar still showing!
    // We can prevent that by setting it to 'initial', and forcing a relayout, before setting it to ''
    document.body.style.overflow = originalOverflow || 'initial';
    var triggerLayout = document.body.clientWidth;
    document.body.style.overflow = originalOverflow;

    return width;
}

function fFalse() {
    return false;
}

function slowF(x) {
    return 1 / (1 + Math.pow(Math.E, (-0.1 * x)));
}

function selectNoText() {
    if (document.body.createTextRange) {
        const range = document.body.createTextRange();
        range.select();
    } else if (window.getSelection) {
        const selection = window.getSelection();
        const range = document.createRange();
        selection.removeAllRanges();
    } else {
        console.warn("Could not unselect text: Unsupported browser.");
    }
}