Smooth Scroll

Universal smooth scrolling for mouse wheel only. Touchpad uses native scrolling.

目前為 2024-11-22 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 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 Universal smooth scrolling for mouse wheel only. Touchpad uses native scrolling.
// @author DXRK1E
// @icon https://i.imgur.com/IAwk6NN.png
// @include *
// @exclude     https://www.youtube.com/*
// @exclude     https://mail.google.com/*
// @version 2.3
// @namespace sttb-dxrk1e
// @license MIT
// ==/UserScript==

(function() {
    'use strict';

    class SmoothScroll {
        constructor() {
            this.config = {
                smoothness: 0.8,        // Increased for more stability
                acceleration: 0.25,      // Reduced to prevent jumps
                minDelta: 0.5,          // Increased minimum threshold
                maxRefreshRate: 144,     // Reduced max refresh rate
                minRefreshRate: 30,
                defaultRefreshRate: 60,
                debug: false
            };

            this.state = {
                isLoaded: false,
                lastFrameTime: 0,
                lastWheelTime: 0,
                lastDelta: 0,
                scrollHistory: [],
                activeScrollElements: new WeakMap()
            };

            this.handleWheel = this.handleWheel.bind(this);
            this.handleClick = this.handleClick.bind(this);
            this.animateScroll = this.animateScroll.bind(this);
            this.detectScrollDevice = this.detectScrollDevice.bind(this);
        }

        init() {
            if (window.top !== window.self || this.state.isLoaded) {
                return;
            }

            if (!window.requestAnimationFrame) {
                window.requestAnimationFrame =
                    window.mozRequestAnimationFrame ||
                    window.webkitRequestAnimationFrame ||
                    window.msRequestAnimationFrame ||
                    ((cb) => setTimeout(cb, 1000 / 60));
            }

            document.addEventListener('wheel', this.handleWheel, {
                passive: false,
                capture: true
            });

            document.addEventListener('mousedown', this.handleClick, true);
            document.addEventListener('touchstart', this.handleClick, true);

            document.addEventListener('visibilitychange', () => {
                if (document.hidden) {
                    this.clearAllScrolls();
                }
            });

            this.state.isLoaded = true;
            this.log('Smooth Scroll Activated (Mouse Only)');
        }

        detectScrollDevice(event) {
            const now = performance.now();
            const timeDelta = now - this.state.lastWheelTime;

            // Update scroll history for better detection
            this.state.scrollHistory.push({
                delta: event.deltaY,
                time: now,
                mode: event.deltaMode
            });

            // Keep only last 5 events
            if (this.state.scrollHistory.length > 5) {
                this.state.scrollHistory.shift();
            }

            // Analyze scroll pattern
            const isConsistent = this.analyzeScrollPattern();

            // More accurate touchpad detection
            const isTouchpad = (
                (Math.abs(event.deltaY) < 5 && event.deltaMode === 0) ||  // Very small precise deltas
                (timeDelta < 32 && this.state.scrollHistory.length > 2) || // Rapid small movements
                !isConsistent || // Inconsistent scroll pattern
                (event.deltaMode === 0 && !Number.isInteger(event.deltaY)) // Fractional pixels
            );

            this.state.lastWheelTime = now;
            this.state.lastDelta = event.deltaY;

            return isTouchpad;
        }

        analyzeScrollPattern() {
            if (this.state.scrollHistory.length < 3) return true;

            const deltas = this.state.scrollHistory.map(entry => entry.delta);
            const avgDelta = deltas.reduce((a, b) => a + Math.abs(b), 0) / deltas.length;

            // Check if deltas are relatively consistent (characteristic of mouse wheels)
            return deltas.every(delta =>
                Math.abs(Math.abs(delta) - avgDelta) < avgDelta * 0.5
            );
        }

        log(...args) {
            if (this.config.debug) {
                console.log('[Smooth Scroll]', ...args);
            }
        }

        getCurrentRefreshRate(timestamp) {
            const frameTime = timestamp - (this.state.lastFrameTime || timestamp);
            this.state.lastFrameTime = timestamp;

            const fps = 1000 / Math.max(frameTime, 1);
            return Math.min(
                Math.max(fps, this.config.minRefreshRate),
                this.config.maxRefreshRate
            );
        }

        getScrollableParents(element, direction) {
            const scrollables = [];

            while (element && element !== document.body) {
                if (this.isScrollable(element, direction)) {
                    scrollables.push(element);
                }
                element = element.parentElement;
            }

            if (this.isScrollable(document.body, direction)) {
                scrollables.push(document.body);
            }

            return scrollables;
        }

        isScrollable(element, direction) {
            if (!element || element === window || element === document) {
                return false;
            }

            const style = window.getComputedStyle(element);
            const overflowY = style['overflow-y'];

            if (overflowY === 'hidden' || overflowY === 'visible') {
                return false;
            }

            const scrollTop = element.scrollTop;
            const scrollHeight = element.scrollHeight;
            const clientHeight = element.clientHeight;

            return direction < 0 ?
                scrollTop > 0 :
                Math.ceil(scrollTop + clientHeight) < scrollHeight;
        }

        handleWheel(event) {
            if (event.defaultPrevented || window.getSelection().toString()) {
                return;
            }

            // If using touchpad, let native scrolling handle it
            if (this.detectScrollDevice(event)) {
                return;
            }

            const scrollables = this.getScrollableParents(event.target, Math.sign(event.deltaY));
            if (!scrollables.length) {
                return;
            }

            const target = scrollables[0];
            let delta = event.deltaY;

            // Normalize delta based on mode
            if (event.deltaMode === 1) { // LINE mode
                const lineHeight = parseInt(getComputedStyle(target).lineHeight) || 20;
                delta *= lineHeight;
            } else if (event.deltaMode === 2) { // PAGE mode
                delta *= target.clientHeight;
            }

            // Apply a more consistent delta transformation
            delta = Math.sign(delta) * Math.sqrt(Math.abs(delta)) * 10;

            this.scroll(target, delta);
            event.preventDefault();
        }

        handleClick(event) {
            const elements = this.getScrollableParents(event.target, 0);
            elements.forEach(element => this.stopScroll(element));
        }

        scroll(element, delta) {
            if (!this.state.activeScrollElements.has(element)) {
                this.state.activeScrollElements.set(element, {
                    pixels: 0,
                    subpixels: 0,
                    direction: Math.sign(delta)
                });
            }

            const scrollData = this.state.activeScrollElements.get(element);

            // Only accumulate scroll if in same direction or very small remaining scroll
            if (Math.sign(delta) === scrollData.direction || Math.abs(scrollData.pixels) < 1) {
                const acceleration = Math.min(
                    1 + (Math.abs(scrollData.pixels) * this.config.acceleration),
                    2
                );
                scrollData.pixels += delta * acceleration;
                scrollData.direction = Math.sign(delta);
            } else {
                // If direction changed, reset acceleration
                scrollData.pixels = delta;
                scrollData.direction = Math.sign(delta);
            }

            if (!scrollData.animating) {
                scrollData.animating = true;
                this.animateScroll(element);
            }
        }

        stopScroll(element) {
            if (this.state.activeScrollElements.has(element)) {
                const scrollData = this.state.activeScrollElements.get(element);
                scrollData.pixels = 0;
                scrollData.subpixels = 0;
                scrollData.animating = false;
            }
        }

        clearAllScrolls() {
            this.state.activeScrollElements = new WeakMap();
        }

        animateScroll(element) {
            if (!this.state.activeScrollElements.has(element)) {
                return;
            }

            const scrollData = this.state.activeScrollElements.get(element);

            if (Math.abs(scrollData.pixels) < this.config.minDelta) {
                scrollData.animating = false;
                return;
            }

            requestAnimationFrame((timestamp) => {
                const refreshRate = this.getCurrentRefreshRate(timestamp);
                const smoothnessFactor = Math.pow(refreshRate, -1 / (refreshRate * this.config.smoothness));

                // More stable scroll amount calculation
                const scrollAmount = scrollData.pixels * (1 - smoothnessFactor);
                const integerPart = Math.trunc(scrollAmount);

                // Accumulate subpixels more accurately
                scrollData.subpixels += (scrollAmount - integerPart);
                let additionalPixels = Math.trunc(scrollData.subpixels);
                scrollData.subpixels -= additionalPixels;

                const totalScroll = integerPart + additionalPixels;

                // Only update if we have a meaningful scroll amount
                if (Math.abs(totalScroll) >= 1) {
                    scrollData.pixels -= totalScroll;

                    try {
                        element.scrollTop += totalScroll;
                    } catch (error) {
                        this.log('Scroll error:', error);
                        this.stopScroll(element);
                        return;
                    }
                }

                if (scrollData.animating) {
                    this.animateScroll(element);
                }
            });
        }
    }

    // Initialize
    const smoothScroll = new SmoothScroll();
    smoothScroll.init();
})();