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