- // ==UserScript==
- // @name Smooth Scroll
- // @description Universal smooth scrolling with improved performance and compatibility. (Exceptions on large sites)
- // @author DXRK1E
- // @icon https://i.imgur.com/IAwk6NN.png
- // @include *
- // @exclude https://www.youtube.com/*
- // @exclude https://mail.google.com/*
- // @version 2.1
- // @namespace sttb-dxrk1e
- // @license MIT
- // ==/UserScript==
-
- (function() {
- 'use strict';
-
- class SmoothScroll {
- constructor() {
- // Configurable settings
- this.config = {
- smoothness: 0.5, // Lower = smoother
- acceleration: 0.5, // Higher = faster acceleration
- minDelta: 0.1, // Minimum scroll delta to process
- maxRefreshRate: 240, // Maximum FPS limit
- minRefreshRate: 30, // Minimum FPS limit
- defaultRefreshRate: 60, // Default FPS when can't detect
- touchpadMultiplier: 1.5, // Multiplier for touchpad sensitivity
- momentumMultiplier: 0.8, // Multiplier for touchpad momentum
- debug: false, // Enable console logging
- isTouchpad: false // Dynamically detected
- };
-
- this.state = {
- isLoaded: false,
- lastFrameTime: 0,
- lastDeltaTime: 0,
- lastDelta: 0,
- consecutiveScrolls: 0,
- 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);
- }
-
- // Initialize the smooth scroll functionality
- 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));
- }
-
- const wheelOptions = {
- passive: false,
- capture: true
- };
-
- document.addEventListener('wheel', this.handleWheel, wheelOptions);
- document.addEventListener('mousedown', this.handleClick, true);
- document.addEventListener('touchstart', this.handleClick, true);
-
- // Add touchpad gesture events
- document.addEventListener('gesturestart', this.handleGesture, true);
- document.addEventListener('gesturechange', this.handleGesture, true);
- document.addEventListener('gestureend', this.handleGesture, true);
-
- document.addEventListener('visibilitychange', () => {
- if (document.hidden) {
- this.clearAllScrolls();
- }
- });
-
- this.state.isLoaded = true;
- this.log('Smooth Scroll Activated');
- }
-
- detectScrollDevice(event) {
- const now = Date.now();
- const timeDelta = now - this.state.lastDeltaTime;
- const deltaDiff = Math.abs(event.deltaY - this.state.lastDelta);
-
- // Touchpad detection heuristics
- if (timeDelta < 50 && deltaDiff < 10) {
- this.state.consecutiveScrolls++;
- } else {
- this.state.consecutiveScrolls = 0;
- }
-
- this.config.isTouchpad = this.state.consecutiveScrolls > 3 ||
- (Math.abs(event.deltaY) < 10 && event.deltaMode === 0);
-
- this.state.lastDeltaTime = now;
- this.state.lastDelta = event.deltaY;
- }
-
- handleGesture(event) {
- // Prevent default gesture behavior
- event.preventDefault();
-
- // Mark as touchpad input
- this.config.isTouchpad = true;
- }
-
-
- 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;
-
- if (direction < 0) {
- return scrollTop > 0;
- } else {
- return Math.ceil(scrollTop + clientHeight) < scrollHeight;
- }
- }
-
- handleWheel(event) {
- if (event.defaultPrevented || window.getSelection().toString()) {
- return;
- }
-
- // Detect if using touchpad
- this.detectScrollDevice(event);
-
- const scrollables = this.getScrollableParents(event.target, Math.sign(event.deltaY));
- if (!scrollables.length) {
- return;
- }
-
- const target = scrollables[0];
- let delta = event.deltaY;
-
- // Apply touchpad-specific adjustments
- if (this.config.isTouchpad) {
- delta *= this.config.touchpadMultiplier;
-
- // Add momentum for smoother touchpad scrolling
- if (Math.abs(delta) < 50) {
- delta *= this.config.momentumMultiplier *
- (1 + (this.state.consecutiveScrolls * 0.1));
- }
- }
-
- 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;
- }
-
- 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
- });
- }
-
- const scrollData = this.state.activeScrollElements.get(element);
-
- const currentSpeed = Math.abs(scrollData.pixels);
- const acceleration = Math.sqrt(currentSpeed / Math.abs(delta) * this.config.acceleration);
- const totalDelta = delta * (1 + acceleration);
-
- scrollData.pixels += totalDelta;
-
- 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);
-
- // Adjust smoothness based on input device
- let smoothness = this.config.smoothness;
- if (this.config.isTouchpad) {
- smoothness *= 1.2; // Slightly smoother for touchpad
- }
-
- const smoothnessFactor = Math.pow(refreshRate, -1 / (refreshRate * smoothness));
-
- const scrollAmount = scrollData.pixels * (1 - smoothnessFactor);
- const integerPart = Math.trunc(scrollAmount);
- const fractionalPart = scrollAmount - integerPart;
-
- scrollData.subpixels += fractionalPart;
- let additionalPixels = Math.trunc(scrollData.subpixels);
- scrollData.subpixels -= additionalPixels;
-
- const totalScroll = integerPart + additionalPixels;
- 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();
- })();