Scroll Everywhere

Scroll entire page smoothly with long left-click and drag.

  1. // ==UserScript==
  2. // @name Scroll Everywhere
  3. // @description Scroll entire page smoothly with long left-click and drag.
  4. // @author tumpio
  5. // @oujs:author tumpio
  6. // @contributor joeytwiddle
  7. // @namespace tumpio@sci.fi
  8. // @homepageURL https://openuserjs.org/scripts/tumpio/Scroll_Everywhere
  9. // @supportURL https://github.com/tumpio/gmscripts
  10. // @icon https://raw.githubusercontent.com/tumpio/gmscripts/master/Scroll_Everywhere/large.png
  11. // @include *
  12. // @grant GM_addStyle
  13. // @run-at document-body
  14. // @version 0.3p
  15. // @license MIT
  16. // ==/UserScript==
  17.  
  18. // Does not work on Steam: https://steamcommunity.com/discussions/forum/10/458604254435648435/?ctp=9
  19.  
  20. // This is a version of tumpio's script which defaults to left-click drag after a long press, and will do a relative scroll of the entire page. I find this more intuitive.
  21.  
  22. /* jshint multistr: true, strict: false, browser: true, devel: true */
  23. /* global escape: true,GM_getValue: true,GM_setValue: true,GM_addStyle: true,GM_xmlhttpRequest: true */
  24.  
  25. /* eslint-disable eqeqeq */
  26. /* eslint-disable curly */
  27. /* eslint-disable no-redeclare */
  28.  
  29. // TODO: add slow scroll start mode
  30. // FIXME: Linux/mac context menu on mousedown, probably needs browser level
  31. // FUTURE: Options dialog
  32.  
  33. // ISSUES:
  34. // The fix for scrollbars works, but we need a similar for for other widgets, for example native dropdown menu widgets.
  35.  
  36. var mouseBtn, reverse, stopOnSecondClick, verticalScroll, startAnimDelay, cursorStyle, down,
  37. scrollevents, scrollBarWidth, cursorMask, isWin, fScrollX, fScrollY, fScroll, slowScrollStart;
  38.  
  39. var middleIsStart, startX, startY, startScrollTop, startScrollLeft, lastScrollHeight;
  40.  
  41. var relativeScrolling, lastX, lastY, scaleX, scaleY, power, offsetMiddle;
  42.  
  43. var lastMiddleClickTime;
  44.  
  45. var startAfterLongPress, longPressTimer, eventBeforeLongPress, longPressStylesAdded;
  46.  
  47. var scrollStartTime, scrollStopTime;
  48.  
  49. var elementToScroll;
  50.  
  51. // NOTE: Do not run on iframes
  52. if (window.top === window.self) {
  53. // USER SETTINGS
  54. mouseBtn = 1; // 1:left, 2:middle, 3:right mouse button
  55. startAfterLongPress = true; // Only start scrolling after a long click
  56. reverse = true; // reversed scroll direction
  57. stopOnSecondClick = false; // keep scrolling until the left mouse button clicked
  58. verticalScroll = false; // vertical scrolling
  59. slowScrollStart = false; // slow scroll start on begin
  60. startAnimDelay = 150; // slow scroll start mode animation delay
  61. cursorStyle = "grab"; // cursor style on scroll
  62. middleIsStart = true; // don't jump when the mouse starts moving
  63. relativeScrolling = false; // scroll the page relative to where we are now
  64. scaleX = 3; // how fast to scroll with relative scrolling
  65. scaleY = 3;
  66. power = 3; // when moving the mouse faster, how quickly should it speed up?
  67. // END
  68.  
  69. fScroll = ((reverse) ? fRevPos : fPos);
  70. fScrollX = ((verticalScroll) ? fScroll : noScrollX);
  71. fScrollY = fScroll;
  72. down = false;
  73. scrollevents = 0;
  74. scrollBarWidth = 2 * getScrollBarWidth();
  75. cursorMask = document.createElement('div');
  76. isWin = window.navigator.appVersion.indexOf("Win") >= 0;
  77. if (cursorStyle === "grab")
  78. cursorStyle = "-webkit-grabbing; cursor: -moz-grabbing";
  79. cursorMask.id = "SE_cursorMask_cursor";
  80. cursorMask.setAttribute("style", "position: fixed; width: 100%; height: 100%; zindex: 5000; top: 0px; left: 0px; cursor: " + cursorStyle + "; background: none; display: none;");
  81. document.body.appendChild(cursorMask);
  82.  
  83. window.addEventListener("mousedown", handleMouseDown, false);
  84. window.addEventListener("mouseup", handleMouseUp, false);
  85. window.addEventListener("click", handleClick, true);
  86. window.addEventListener('paste', handlePaste, true);
  87. }
  88.  
  89. function handleMouseDown(e) {
  90. // From: https://stackoverflow.com/questions/10045423/determine-whether-user-clicking-scrollbar-or-content-onclick-for-native-scroll
  91. var wasClickOnScrollbar = e.target.clientWidth > 0 && e.offsetX > e.target.clientWidth || e.target.clientHeight > 0 && e.offsetY > e.target.clientHeight;
  92. if (wasClickOnScrollbar) {
  93. //console.log('Ignoring click on scrollbar:', e, `${e.offsetX} > ${e.target.clientWidth} || ${e.offsetY} > ${e.target.clientHeight}`);
  94. return;
  95. }
  96. if (e.which == mouseBtn) {
  97. if (startAfterLongPress) {
  98. startLongPress(e);
  99. } else {
  100. if (!down) {
  101. start(e);
  102. } else {
  103. stop();
  104. }
  105. }
  106. }
  107. }
  108.  
  109. function handleMouseUp(e) {
  110. if (e.which == 2) {
  111. lastMiddleClickTime = Date.now();
  112. }
  113. if (startAfterLongPress) {
  114. cancelLongPress();
  115. }
  116. }
  117.  
  118. function handleClick(e) {
  119. // If we were just in scrolling mode, then we don't want other listeners to see this click event
  120. var justStoppedScrolling = Date.now() <= scrollStopTime + 20;
  121. // But if we went in and out of scrolling mode in a short time, then this was actually a click
  122. var wasShortClick = !startAfterLongPress && scrollStopTime - scrollStartTime < 200;
  123. if (justStoppedScrolling && !wasShortClick) {
  124. //console.info("MUTING click event");
  125. e.preventDefault();
  126. e.stopPropagation();
  127. }
  128. }
  129.  
  130. function handlePaste(e) {
  131. var timeSinceLastMiddleClick = Date.now() - lastMiddleClickTime;
  132. //console.log("Pasting (" + timeSinceLastMiddleClick + "ms):", (event.clipboardData || window.clipboardData).getData('text'));
  133.  
  134. // If you use middle button for scrolling on Linux, then you might be sending a paste event every time you use this scroller.
  135. // Depending on the contents of your clipboard, that could be a privacy leak!
  136. // Therefore we disable paste events if they come after a middle click (if the user uses middle click for scrolling).
  137. //
  138. // Note this solution is still not entirely safe. There could be an event listener registered before us, which would see the paste.
  139. // Another option is to disable middle-click but this also isn't trivial to do universally: https://askubuntu.com/questions/4507
  140. //
  141. // TODO: It would be better to check if this was a middle-click drag (i.e. a scroll). A plain short middle-click we could interpret as a paste.
  142.  
  143. if (mouseBtn == 2 && timeSinceLastMiddleClick < 200) {
  144. e.preventDefault();
  145. e.stopPropagation();
  146. return false;
  147. }
  148. }
  149.  
  150. function startLongPress(e) {
  151. cancelLongPress();
  152. eventBeforeLongPress = e;
  153. longPressTimer = setTimeout(longPressDetected, 500);
  154. window.addEventListener("mousemove", cancelLongPress, false);
  155. }
  156.  
  157. function longPressDetected() {
  158. // Cleanup
  159. cancelLongPress();
  160. if (mouseBtn == 1) {
  161. // After a long press with the left mouse button, the browser will start selecting text, which will get messy when we scroll
  162. // So we try to cancel that selection
  163. selectNoText();
  164. }
  165. start(eventBeforeLongPress);
  166. // Give the user a visual indication that scrolling mode has started
  167. cursorMask.style.display = "";
  168. // A stronger indication: a ripple effect starting from the mouse location
  169. // This is especially useful when our pointer change is overriden by the page's CSS
  170. // Based on: https://css-tricks.com/how-to-recreate-the-ripple-effect-of-material-design-buttons/
  171. if (!longPressStylesAdded) {
  172. GM_addStyle(`
  173. #scroll-anywhere-ripple-animation {
  174. position: fixed;
  175. width: 20px;
  176. height: 20px;
  177. border-radius: 50%;
  178. transform: scale(0);
  179. animation: ripple 600ms ease-out;
  180. background-color: #aaa8;
  181. z-index: 999999;
  182. pointer-events: none;
  183. }
  184. @keyframes ripple {
  185. from {
  186. transform: scale(0);
  187. opacity: 1;
  188. }
  189. to {
  190. transform: scale(16);
  191. opacity: 0;
  192. }
  193. }
  194. `);
  195. longPressStylesAdded = true;
  196. }
  197. var circleDiv = document.createElement('div');
  198. circleDiv.id = 'scroll-anywhere-ripple-animation';
  199. circleDiv.style.left = (eventBeforeLongPress.clientX - 10) + 'px';
  200. circleDiv.style.top = (eventBeforeLongPress.clientY - 10) + 'px';
  201. document.body.appendChild(circleDiv);
  202. setTimeout(() => {
  203. circleDiv.parentNode.removeChild(circleDiv);
  204. }, 2000);
  205. }
  206.  
  207. function cancelLongPress() {
  208. clearTimeout(longPressTimer);
  209. window.removeEventListener("mousemove", cancelLongPress);
  210. }
  211.  
  212. function start(e) {
  213. down = true;
  214. elementToScroll = findElementToScroll(e.target);
  215. //console.log('Will do scrolling on:', elementToScroll, elementToScroll.scrollTop, elementToScroll.scrollHeight, getComputedStyle(elementToScroll).overflow);
  216. scrollStartTime = Date.now();
  217. setStartData(e);
  218. lastX = e.clientX;
  219. lastY = e.clientY;
  220. if (!slowScrollStart)
  221. scroll(e);
  222. window.addEventListener("mousemove", waitScroll, false);
  223. if (!stopOnSecondClick)
  224. window.addEventListener("mouseup", stop, false);
  225. }
  226.  
  227. function findElementToScroll(elem) {
  228. if (elem.clientHeight > 0 && elem.scrollHeight > elem.clientHeight) {
  229. var overflow = getComputedStyle(elem).overflow;
  230. if (overflow === '' || overflow.match(/(auto|scroll|overlay)/)) {
  231. //console.log('overflow:', overflow);
  232. return elem;
  233. }
  234. }
  235. if (!elem.parentNode) {
  236. // On some sites, documentElement works better than body
  237. return document.documentElement.scrollHeight > 0 ? document.documentElement : document.body;
  238. }
  239. return findElementToScroll(elem.parentNode);
  240. }
  241.  
  242. function setStartData(e) {
  243. lastScrollHeight = getScrollHeight();
  244. startX = e.clientX;
  245. startY = e.clientY;
  246. // On some pages, body.scrollTop changes whilst documentElement.scrollTop remains 0.
  247. // For example: https://docs.kde.org/trunk5/en/kde-workspace/kcontrol/autostart/index.html
  248. // See: https://stackoverflow.com/questions/19618545
  249. startScrollTop = elementToScroll.scrollTop || 0;
  250. startScrollLeft = elementToScroll.scrollLeft || 0;
  251. if (elementToScroll === document.documentElement || elementToScroll === document.body) {
  252. startScrollTop = document.documentElement.scrollTop || document.body.scrollTop || 0;
  253. startScrollLeft = document.documentElement.scrollLeft || document.body.scrollLeft || 0;
  254. }
  255. }
  256.  
  257. function waitScroll(e) {
  258. scrollevents += 1;
  259. if (scrollevents > 2) {
  260. cursorMask.style.display = "";
  261. if (isWin)
  262. document.oncontextmenu = fFalse;
  263. window.removeEventListener("mousemove", waitScroll, false);
  264. window.addEventListener("mousemove", scroll, false);
  265. }
  266. }
  267.  
  268. function scroll(e) {
  269. // If the site has just changed the height of the webpage (e.g. by auto-loading more content)
  270. // then we must adapt to the new height to avoid jumping.
  271. if (lastScrollHeight !== getScrollHeight()) {
  272. setStartData(e);
  273. }
  274. //scrollevents += 1;
  275. if (!stopOnSecondClick && e.buttons === 0) {
  276. stop();
  277. return;
  278. }
  279. if (relativeScrolling) {
  280. var diffX = e.clientX - lastX;
  281. var diffY = e.clientY - lastY;
  282. var distance = Math.sqrt(diffX * diffX + diffY * diffY);
  283. var velocity = 1 + distance * power / 100;
  284. var reverseScale = reverse ? -1 : 1;
  285. //doScrollTo(window, window.scrollX + diffX * scaleX * velocity * reverseScale, window.scrollY + diffY * scaleY * velocity * reverseScale);
  286. doScrollTo(elementToScroll, elementToScroll.scrollLeft + diffX * scaleX * velocity * reverseScale, elementToScroll.scrollTop + diffY * scaleY * velocity * reverseScale);
  287. lastX = e.clientX;
  288. lastY = e.clientY;
  289. return;
  290. }
  291. var newX = fScrollX(
  292. window.innerWidth - scrollBarWidth,
  293. getScrollWidth() - getClientWidth(),
  294. e.clientX);
  295. var newY = fScrollY(
  296. window.innerHeight - scrollBarWidth,
  297. getScrollHeight() - getClientHeight(),
  298. e.clientY);
  299. doScrollTo(elementToScroll, newX, newY);
  300. }
  301.  
  302. function doScrollTo(elem, x, y) {
  303. //console.log(`Doing scroll: ${x} ${y}`);
  304. // For normal HTML elements
  305. elem.scrollTo(x, y);
  306. // For React Native elements
  307. elem.scrollTo({ x: x, y: y, animated: false });
  308. if (elem === document.documentElement) {
  309. document.body.scrollTo(x, y);
  310. document.body.scrollTo({ x: x, y: y, animated: false });
  311. }
  312. if (elem === document.body) {
  313. document.documentElement.scrollTo(x, y);
  314. document.documentElement.scrollTo({ x: x, y: y, animated: false });
  315. }
  316. }
  317.  
  318. function stop() {
  319. cursorMask.style.display = "none";
  320. if (isWin)
  321. document.oncontextmenu = !fFalse;
  322. down = false;
  323. scrollStopTime = Date.now();
  324. scrollevents = 0;
  325. window.removeEventListener("mouseup", stop, false);
  326. window.removeEventListener("mousemove", scroll, false);
  327. window.removeEventListener("mousemove", waitScroll, false);
  328. }
  329.  
  330. function noScrollX() {
  331. return elementToScroll.scrollLeft;
  332. }
  333.  
  334. function fPos(win, doc, pos) {
  335. if (middleIsStart) {
  336. if (pos < startY) {
  337. return startScrollTop * pos / startY;
  338. } else {
  339. return startScrollTop + (doc - startScrollTop) * (pos - startY) / (win - startY);
  340. }
  341. }
  342. return doc * (pos / win);
  343. }
  344.  
  345. function fRevPos(win, doc, pos) {
  346. if (middleIsStart) {
  347. if (pos < startY) {
  348. return startScrollTop + (doc - startScrollTop) * (startY - pos) / startY;
  349. } else {
  350. return startScrollTop - startScrollTop * (pos - startY) / (win - startY);
  351. }
  352. }
  353. return doc - fPos(win, doc, pos);
  354. }
  355.  
  356. function getScrollHeight() {
  357. return elementToScroll.scrollHeight || 0;
  358. }
  359.  
  360. function getScrollWidth() {
  361. return elementToScroll.scrollWidth || 0;
  362. }
  363.  
  364. function getClientHeight(e) {
  365. // Sometimes documentElement will return the full scrollHeight, but we want the smaller visible portal that body returns
  366. if (elementToScroll === document.documentElement || elementToScroll === document.body) {
  367. return Math.min(document.documentElement.clientHeight, document.body.clientHeight);
  368. }
  369. return elementToScroll.clientHeight || 0;
  370. }
  371.  
  372. function getClientWidth(e) {
  373. if (elementToScroll === document.documentElement || elementToScroll === document.body) {
  374. return Math.min(document.documentElement.clientWidth, document.body.clientWidth);
  375. }
  376. return elementToScroll.clientWidth || 0;
  377. }
  378.  
  379. function getScrollBarWidth() {
  380. var originalOverflow = document.body.style.overflow;
  381. document.body.style.overflow = 'hidden';
  382. var width = document.body.clientWidth;
  383. document.body.style.overflow = 'scroll';
  384. width -= document.body.clientWidth;
  385. if (!width) width = document.body.offsetWidth - document.body.clientWidth;
  386.  
  387. // Now we set overflow back to how it was
  388. // But if style === '' then Firefox will sometimes leave the temporary scrollbar still showing!
  389. // We can prevent that by setting it to 'initial', and forcing a relayout, before setting it to ''
  390. document.body.style.overflow = originalOverflow || 'initial';
  391. var triggerLayout = document.body.clientWidth;
  392. document.body.style.overflow = originalOverflow;
  393.  
  394. return width;
  395. }
  396.  
  397. function fFalse() {
  398. return false;
  399. }
  400.  
  401. function slowF(x) {
  402. return 1 / (1 + Math.pow(Math.E, (-0.1 * x)));
  403. }
  404.  
  405. function selectNoText() {
  406. if (document.body.createTextRange) {
  407. const range = document.body.createTextRange();
  408. range.select();
  409. } else if (window.getSelection) {
  410. const selection = window.getSelection();
  411. const range = document.createRange();
  412. selection.removeAllRanges();
  413. } else {
  414. console.warn("Could not unselect text: Unsupported browser.");
  415. }
  416. }