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