Smooth Scroll

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

当前为 2024-11-22 提交的版本,查看 最新版本

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

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址