Smooth Scroll

Universal smooth scrolling with improved performance and compatibility. (Exceptions on large sites)

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

  1. // ==UserScript==
  2. // @name Smooth Scroll
  3. // @description Universal smooth scrolling with improved performance and compatibility. (Exceptions on large sites)
  4. // @author DXRK1E
  5. // @icon https://i.imgur.com/IAwk6NN.png
  6. // @include *
  7. // @exclude https://www.youtube.com/*
  8. // @exclude https://mail.google.com/*
  9. // @version 2.1
  10. // @namespace sttb-dxrk1e
  11. // @license MIT
  12. // ==/UserScript==
  13.  
  14. (function() {
  15. 'use strict';
  16.  
  17. class SmoothScroll {
  18. constructor() {
  19. // Configurable settings
  20. this.config = {
  21. smoothness: 0.5, // Lower = smoother
  22. acceleration: 0.5, // Higher = faster acceleration
  23. minDelta: 0.1, // Minimum scroll delta to process
  24. maxRefreshRate: 240, // Maximum FPS limit
  25. minRefreshRate: 30, // Minimum FPS limit
  26. defaultRefreshRate: 60, // Default FPS when can't detect
  27. touchpadMultiplier: 1.5, // Multiplier for touchpad sensitivity
  28. momentumMultiplier: 0.8, // Multiplier for touchpad momentum
  29. debug: false, // Enable console logging
  30. isTouchpad: false // Dynamically detected
  31. };
  32.  
  33. this.state = {
  34. isLoaded: false,
  35. lastFrameTime: 0,
  36. lastDeltaTime: 0,
  37. lastDelta: 0,
  38. consecutiveScrolls: 0,
  39. activeScrollElements: new WeakMap()
  40. };
  41.  
  42. this.handleWheel = this.handleWheel.bind(this);
  43. this.handleClick = this.handleClick.bind(this);
  44. this.animateScroll = this.animateScroll.bind(this);
  45. this.detectScrollDevice = this.detectScrollDevice.bind(this);
  46. }
  47.  
  48. // Initialize the smooth scroll functionality
  49. init() {
  50. if (window.top !== window.self || this.state.isLoaded) {
  51. return;
  52. }
  53.  
  54. if (!window.requestAnimationFrame) {
  55. window.requestAnimationFrame =
  56. window.mozRequestAnimationFrame ||
  57. window.webkitRequestAnimationFrame ||
  58. window.msRequestAnimationFrame ||
  59. ((cb) => setTimeout(cb, 1000 / 60));
  60. }
  61.  
  62. const wheelOptions = {
  63. passive: false,
  64. capture: true
  65. };
  66.  
  67. document.addEventListener('wheel', this.handleWheel, wheelOptions);
  68. document.addEventListener('mousedown', this.handleClick, true);
  69. document.addEventListener('touchstart', this.handleClick, true);
  70.  
  71. // Add touchpad gesture events
  72. document.addEventListener('gesturestart', this.handleGesture, true);
  73. document.addEventListener('gesturechange', this.handleGesture, true);
  74. document.addEventListener('gestureend', this.handleGesture, true);
  75.  
  76. document.addEventListener('visibilitychange', () => {
  77. if (document.hidden) {
  78. this.clearAllScrolls();
  79. }
  80. });
  81.  
  82. this.state.isLoaded = true;
  83. this.log('Smooth Scroll Activated');
  84. }
  85.  
  86. detectScrollDevice(event) {
  87. const now = Date.now();
  88. const timeDelta = now - this.state.lastDeltaTime;
  89. const deltaDiff = Math.abs(event.deltaY - this.state.lastDelta);
  90.  
  91. // Touchpad detection heuristics
  92. if (timeDelta < 50 && deltaDiff < 10) {
  93. this.state.consecutiveScrolls++;
  94. } else {
  95. this.state.consecutiveScrolls = 0;
  96. }
  97.  
  98. this.config.isTouchpad = this.state.consecutiveScrolls > 3 ||
  99. (Math.abs(event.deltaY) < 10 && event.deltaMode === 0);
  100.  
  101. this.state.lastDeltaTime = now;
  102. this.state.lastDelta = event.deltaY;
  103. }
  104.  
  105. handleGesture(event) {
  106. // Prevent default gesture behavior
  107. event.preventDefault();
  108.  
  109. // Mark as touchpad input
  110. this.config.isTouchpad = true;
  111. }
  112.  
  113.  
  114. log(...args) {
  115. if (this.config.debug) {
  116. console.log('[Smooth Scroll]', ...args);
  117. }
  118. }
  119.  
  120. getCurrentRefreshRate(timestamp) {
  121. const frameTime = timestamp - (this.state.lastFrameTime || timestamp);
  122. this.state.lastFrameTime = timestamp;
  123.  
  124. const fps = 1000 / Math.max(frameTime, 1);
  125. return Math.min(
  126. Math.max(fps, this.config.minRefreshRate),
  127. this.config.maxRefreshRate
  128. );
  129. }
  130.  
  131. getScrollableParents(element, direction) {
  132. const scrollables = [];
  133.  
  134. while (element && element !== document.body) {
  135. if (this.isScrollable(element, direction)) {
  136. scrollables.push(element);
  137. }
  138. element = element.parentElement;
  139. }
  140.  
  141. if (this.isScrollable(document.body, direction)) {
  142. scrollables.push(document.body);
  143. }
  144.  
  145. return scrollables;
  146. }
  147.  
  148. isScrollable(element, direction) {
  149. if (!element || element === window || element === document) {
  150. return false;
  151. }
  152.  
  153. const style = window.getComputedStyle(element);
  154. const overflowY = style['overflow-y'];
  155.  
  156. if (overflowY === 'hidden' || overflowY === 'visible') {
  157. return false;
  158. }
  159.  
  160. const scrollTop = element.scrollTop;
  161. const scrollHeight = element.scrollHeight;
  162. const clientHeight = element.clientHeight;
  163.  
  164. if (direction < 0) {
  165. return scrollTop > 0;
  166. } else {
  167. return Math.ceil(scrollTop + clientHeight) < scrollHeight;
  168. }
  169. }
  170.  
  171. handleWheel(event) {
  172. if (event.defaultPrevented || window.getSelection().toString()) {
  173. return;
  174. }
  175.  
  176. // Detect if using touchpad
  177. this.detectScrollDevice(event);
  178.  
  179. const scrollables = this.getScrollableParents(event.target, Math.sign(event.deltaY));
  180. if (!scrollables.length) {
  181. return;
  182. }
  183.  
  184. const target = scrollables[0];
  185. let delta = event.deltaY;
  186.  
  187. // Apply touchpad-specific adjustments
  188. if (this.config.isTouchpad) {
  189. delta *= this.config.touchpadMultiplier;
  190.  
  191. // Add momentum for smoother touchpad scrolling
  192. if (Math.abs(delta) < 50) {
  193. delta *= this.config.momentumMultiplier *
  194. (1 + (this.state.consecutiveScrolls * 0.1));
  195. }
  196. }
  197.  
  198. if (event.deltaMode === 1) { // LINE mode
  199. const lineHeight = parseInt(getComputedStyle(target).lineHeight) || 20;
  200. delta *= lineHeight;
  201. } else if (event.deltaMode === 2) { // PAGE mode
  202. delta *= target.clientHeight;
  203. }
  204.  
  205. this.scroll(target, delta);
  206. event.preventDefault();
  207. }
  208.  
  209. handleClick(event) {
  210. const elements = this.getScrollableParents(event.target, 0);
  211. elements.forEach(element => this.stopScroll(element));
  212. }
  213.  
  214. scroll(element, delta) {
  215. if (!this.state.activeScrollElements.has(element)) {
  216. this.state.activeScrollElements.set(element, {
  217. pixels: 0,
  218. subpixels: 0
  219. });
  220. }
  221.  
  222. const scrollData = this.state.activeScrollElements.get(element);
  223.  
  224. const currentSpeed = Math.abs(scrollData.pixels);
  225. const acceleration = Math.sqrt(currentSpeed / Math.abs(delta) * this.config.acceleration);
  226. const totalDelta = delta * (1 + acceleration);
  227.  
  228. scrollData.pixels += totalDelta;
  229.  
  230. if (!scrollData.animating) {
  231. scrollData.animating = true;
  232. this.animateScroll(element);
  233. }
  234. }
  235.  
  236. stopScroll(element) {
  237. if (this.state.activeScrollElements.has(element)) {
  238. const scrollData = this.state.activeScrollElements.get(element);
  239. scrollData.pixels = 0;
  240. scrollData.subpixels = 0;
  241. scrollData.animating = false;
  242. }
  243. }
  244.  
  245. clearAllScrolls() {
  246. this.state.activeScrollElements = new WeakMap();
  247. }
  248.  
  249. animateScroll(element) {
  250. if (!this.state.activeScrollElements.has(element)) {
  251. return;
  252. }
  253.  
  254. const scrollData = this.state.activeScrollElements.get(element);
  255.  
  256. if (Math.abs(scrollData.pixels) < this.config.minDelta) {
  257. scrollData.animating = false;
  258. return;
  259. }
  260.  
  261. requestAnimationFrame((timestamp) => {
  262. const refreshRate = this.getCurrentRefreshRate(timestamp);
  263.  
  264. // Adjust smoothness based on input device
  265. let smoothness = this.config.smoothness;
  266. if (this.config.isTouchpad) {
  267. smoothness *= 1.2; // Slightly smoother for touchpad
  268. }
  269.  
  270. const smoothnessFactor = Math.pow(refreshRate, -1 / (refreshRate * smoothness));
  271.  
  272. const scrollAmount = scrollData.pixels * (1 - smoothnessFactor);
  273. const integerPart = Math.trunc(scrollAmount);
  274. const fractionalPart = scrollAmount - integerPart;
  275.  
  276. scrollData.subpixels += fractionalPart;
  277. let additionalPixels = Math.trunc(scrollData.subpixels);
  278. scrollData.subpixels -= additionalPixels;
  279.  
  280. const totalScroll = integerPart + additionalPixels;
  281. scrollData.pixels -= totalScroll;
  282.  
  283. try {
  284. element.scrollTop += totalScroll;
  285. } catch (error) {
  286. this.log('Scroll error:', error);
  287. this.stopScroll(element);
  288. return;
  289. }
  290.  
  291. if (scrollData.animating) {
  292. this.animateScroll(element);
  293. }
  294. });
  295. }
  296. }
  297.  
  298. // Initialize
  299. const smoothScroll = new SmoothScroll();
  300. smoothScroll.init();
  301. })();