Smooth Scroll

Enables smooth page scrolling using JavaScript. Improved from an initial concept by Winceptor.

// ==UserScript==
// @name Smooth Scroll
// @description  Enables smooth page scrolling using JavaScript. Improved from an initial concept by Winceptor.
// @author DXRK1E
// @icon https://i.imgur.com/IAwk6NN.png
// @include *
// @version 2.4
// @namespace sttb-dxrk1e
// @license MIT
// @grant none
// ==/UserScript==

(function () {
    'use strict';

    const SmoothScroll = {};

    // Settings (Adjust these to your preference)
    SmoothScroll.settings = {
        scrollSmoothness: 0.5,    // Controls how smooth the animation is (0-1, lower is smoother)
        scrollAcceleration: 0.5, // Controls how quickly the scroll speed increases
        debugMode: 0,          // Debugging (Set to 1 for console logs, 0 for none)
        targetRefreshRate: 60,   // Animation refresh rate target
        maxRefreshRate: 300,   // Animation refresh rate upper limit
        minRefreshRate: 1,     // Animation refresh rate lower limit
        animationDuration: 500,  // Max duration of smooth animation in milliseconds
        scrollThreshold: 2,      // Minimum distance to trigger smooth scrolling
        passiveEventListeners: false, // Set to true if you want to use passive event listeners
    };

    // Animation state tracking
    const scrollState = new WeakMap(); // Use WeakMap to avoid memory leaks

    // --- Helper Functions for Scroll Data Management ---

     // Manages sub-pixel scroll offset for smoother animation
    function getSubPixelOffset(element, newOffset) {
        let elementState = scrollState.get(element) || {};
        if (newOffset !== undefined) {
            elementState.subPixelOffset = newOffset;
        }
        scrollState.set(element, elementState);
        return elementState.subPixelOffset || 0;
    }

    // Manages accumulated scroll amount in integer pixels
    function getPixelScrollAmount(element, newAmount) {
       let elementState = scrollState.get(element) || {};
        if (newAmount !== undefined) {
            elementState.pixelScrollAmount = newAmount;
            getSubPixelOffset(element, 0);  // Reset subpixel offset when full pixels are scrolled
        }
        scrollState.set(element, elementState);
        return elementState.pixelScrollAmount || 0;
    }


   // --- Core Animation Logic ---

    function animateScrolling(targetElement, refreshRate, startTime = performance.now()) {
         let currentSubPixelOffset = getSubPixelOffset(targetElement);
         let currentPixelScroll = getPixelScrollAmount(targetElement);
        const currentTime = performance.now();
        const elapsedTime = currentTime - startTime;


         const scrollDirection = currentPixelScroll > 0 ? 1 : currentPixelScroll < 0 ? -1 : 0;
        const scrollRatio = 1 - Math.pow(refreshRate, -1 / (refreshRate * SmoothScroll.settings.scrollSmoothness));
        const scrollChange = currentPixelScroll * scrollRatio;



         if ((Math.abs(currentPixelScroll) > SmoothScroll.settings.scrollThreshold) && elapsedTime < SmoothScroll.settings.animationDuration ) {

            const fullPixelScrolls = Math.floor(Math.abs(scrollChange)) * scrollDirection;
            const subPixelChange = scrollChange - fullPixelScrolls;
             const additionalPixelScrolls = Math.floor(Math.abs(currentSubPixelOffset + subPixelChange)) * scrollDirection;
            const remainingSubPixelOffset = currentSubPixelOffset + subPixelChange - additionalPixelScrolls;

            getPixelScrollAmount(targetElement, currentPixelScroll - fullPixelScrolls - additionalPixelScrolls);
            getSubPixelOffset(targetElement, remainingSubPixelOffset);

             const totalScrollDelta = fullPixelScrolls + additionalPixelScrolls;

             targetElement.style.scrollBehavior = "auto"; // Ensure smooth scroll doesn't interfere
             targetElement.scrollTop += totalScrollDelta;
             targetElement.isScrolling = true;


            requestAnimationFrameUpdate(newRefreshRate => {
               animateScrolling(targetElement, newRefreshRate, startTime);
            });

        } else {
             requestAnimationFrameUpdate(() => {
               getPixelScrollAmount(targetElement, 0); // Reset scroll amount once complete
             });
             targetElement.isScrolling = false;
         }
    }

     // Used to get a dynamically calculated refresh rate
    function requestAnimationFrameUpdate(callback) {
        const startTime = performance.now();
        window.requestAnimationFrame(() => {
            const endTime = performance.now();
            const frameDuration = endTime - startTime;
            const calculatedFps = 1000 / Math.max(frameDuration, 1);
            const refreshRate = Math.min(Math.max(calculatedFps, SmoothScroll.settings.minRefreshRate), SmoothScroll.settings.maxRefreshRate);
            callback(refreshRate);
        });
    }

     // --- Exposed API ---
    SmoothScroll.stop = function (targetElement) {
        if (targetElement) {
             getPixelScrollAmount(targetElement, 0);
        }
    };

    SmoothScroll.start = function (targetElement, scrollAmount) {
        if (targetElement) {
           const currentScrollAmount =  getPixelScrollAmount(targetElement, scrollAmount);
            if (!targetElement.isScrolling) {
                animateScrolling(targetElement, SmoothScroll.settings.targetRefreshRate);
            }
        }
    };

    // --- Helper functions for detecting scrollable elements ---

    // Checks if an element is scrollable vertically
    function canScroll(element, direction) {
        if (direction < 0) {
            return element.scrollTop > 0; // Check if can scroll up
        }
        if (direction > 0) { // check if can scroll down
             if (element === document.body) { // Special body case
                if(element.scrollTop === 0) {
                    element.scrollTop = 3;
                    if(element.scrollTop === 0){
                        return false;
                    }
                    element.scrollTop = 0;
                }
               return Math.round(element.clientHeight + element.scrollTop) < element.offsetHeight;
           }
           return Math.round(element.clientHeight + element.scrollTop) < element.scrollHeight;
       }
        return false; // No direction, so can't scroll
    }

     // Checks if an element has a scrollbar (and isn't set to not scroll)
    function hasScrollbar(element) {
        if (element === window || element === document) {
            return false; // Window and Document always have scroll (if needed)
        }
        if(element === document.body) {
          return window.getComputedStyle(document.body)['overflow-y'] !== "hidden";
        }
         if (element === document.documentElement) {
              return window.innerWidth > document.documentElement.clientWidth;
         }

        const style = window.getComputedStyle(element);
        return style['overflow-y'] !== "hidden" && style['overflow-y'] !== "visible";
    }

    // Checks both scrollability and scrollbar presence
    function isScrollable(element, direction) {
        if(element === document.body) {
           // return false;
        }
        const canScrollCheck = canScroll(element, direction);
        if (!canScrollCheck) {
            return false;
        }
        const hasScrollbarCheck = hasScrollbar(element);
        if (!hasScrollbarCheck) {
            return false;
        }
        return true;
    }


     // ---- Event Handling Utilities ---
    function getEventPath(event) {
        if (event.path) {
            return event.path;
        }
        if (event.composedPath) {
            return event.composedPath();
        }
        return null;
    }


    // Finds the first scrollable parent element in the event path
     function getScrollTarget(event) {
        const direction = event.deltaY;
        const path = getEventPath(event);
        if (!path) {
            return null;
        }

        for (let i = 0; i < path.length; i++) {
            const element = path[i];
            if (isScrollable(element, direction)) {
                return element;
            }
        }
        return null;
    }

       // Get style property with error-checking
    function getStyleProperty(element, styleProperty) {
         try {
            if (window.getComputedStyle) {
                const value = document.defaultView.getComputedStyle(element, null).getPropertyValue(styleProperty);
                if (value) {
                    return parseInt(value, 10);
                }
            } else if (element.currentStyle) {
                const value = element.currentStyle[styleProperty.encamel()];
                if (value) {
                    return parseInt(value, 10);
                }
            }
        } catch (e) {
            if(SmoothScroll.settings.debugMode) {
               console.error(`Error getting style property ${styleProperty} on element:`, element, e);
            }
            return null;
         }
        return null;
    }


    // --- Event Handlers ---

    function stopActiveScroll(event) {
        const path = getEventPath(event);
        if (!path) {
            return;
        }
        path.forEach(element => SmoothScroll.stop(element));
    }

    function startSmoothScroll(event, targetElement) {
        if (event.defaultPrevented) {
           return; // Ignore if prevented already
       }

        let deltaAmount = event.deltaY;

         // Adjust delta based on deltaMode (lines/pages)
        if (event.deltaMode && event.deltaMode === 1) {
            const lineHeight = getStyleProperty(targetElement, 'line-height');
            if (lineHeight && lineHeight > 0) {
                deltaAmount *= lineHeight;
            }
        }
        if (event.deltaMode && event.deltaMode === 2) {
            const pageHeight = targetElement.clientHeight;
            if (pageHeight && pageHeight > 0) {
                deltaAmount *= pageHeight;
            }
        }

          const currentPixelAmount = getPixelScrollAmount(targetElement);
          const accelerationRatio = Math.sqrt(Math.abs(currentPixelAmount / deltaAmount * SmoothScroll.settings.scrollAcceleration));
          const acceleration = Math.round(deltaAmount * accelerationRatio);
          const totalScroll = currentPixelAmount + deltaAmount + acceleration;
          SmoothScroll.start(targetElement, totalScroll);
          event.preventDefault(); // Prevents default behavior
    }

    function handleWheel(event) {
        const targetElement = getScrollTarget(event);
        if (targetElement) {
            startSmoothScroll(event, targetElement);
        }
    }


    // Handles click event to stop scrolling animation if it is in progress.
    function handleClick(event) {
        stopActiveScroll(event);
    }

   // --- Initialization ---
    function initialize() {
        if (window.top !== window.self) {
            return; // Exit if in an iframe
        }
        if (window.SmoothScroll && window.SmoothScroll.isLoaded) {
            return; // Exit if already loaded
        }
         if (!window.requestAnimationFrame) { // Fallback for older browsers
            window.requestAnimationFrame = window.mozRequestAnimationFrame || window.webkitRequestAnimationFrame;
         }


        const eventOptions = SmoothScroll.settings.passiveEventListeners ? { passive: false } : false;

         document.documentElement.addEventListener("wheel", function (e) {
           handleWheel(e);
        }, eventOptions); // disable passive listener to prevent scroll.

        document.documentElement.addEventListener("mousedown", function (e) {
           handleClick(e);
        });

        window.SmoothScroll = SmoothScroll;
        window.SmoothScroll.isLoaded = true;

        if (SmoothScroll.settings.debugMode > 0) {
            console.log("Enhanced Smooth Scroll: loaded");
        }
    }


     // Polyfill for String.encamel (if not supported)
    if (!String.prototype.encamel) {
      String.prototype.encamel = function() {
         return this.replace(/-([a-z])/g, function (g) { return g[1].toUpperCase(); });
      };
    }

    initialize();
})();