Smooth Scroll

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

目前為 2025-01-29 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==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();
})();