From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 From: Modrinth Enhanced Date: Mon, 14 Sep 2026 19:17:53 +0200 Subject: [PATCH] Scroll with the middle mouse button --- apps/app-frontend/src/App.vue | 8 + .../src/assets/stylesheets/global.scss | 50 +++ apps/app-frontend/src/helpers/autoscroll.ts | 290 ++++++++++++++++++ 3 files changed, 348 insertions(+) create mode 100644 apps/app-frontend/src/helpers/autoscroll.ts diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index d681a14..2102c4c 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -124,6 +124,7 @@ import { } from '@/helpers/ads.js' import { debugAnalytics, initAnalytics, trackEvent } from '@/helpers/analytics' import { check_reachable } from '@/helpers/auth.js' +import { installAutoscroll } from '@/helpers/autoscroll.ts' import { get_user, get_user_many, get_version } from '@/helpers/cache.js' import { gameSettingsQueryOptions } from '@/helpers/game-options' import { install_create_modpack_instance, install_get_modpack_preview } from '@/helpers/install' @@ -600,11 +601,18 @@ onMounted(async () => { document.querySelector('body').addEventListener('auxclick', handleAuxClick) document.querySelector('body').addEventListener('contextmenu', handleContextMenu) document.addEventListener('fullscreenchange', handleFullscreenChange) + // Chromium, and so WebView2 on Windows, already autoscrolls by itself + if (!navigator.userAgent.includes('Windows')) { + uninstallAutoscroll = installAutoscroll() + } checkUpdates() }) +let uninstallAutoscroll + onUnmounted(async () => { + uninstallAutoscroll?.() document.querySelector('body').removeEventListener('click', handleClick) document.querySelector('body').removeEventListener('auxclick', handleAuxClick) document.querySelector('body').removeEventListener('contextmenu', handleContextMenu) diff --git a/apps/app-frontend/src/assets/stylesheets/global.scss b/apps/app-frontend/src/assets/stylesheets/global.scss index ba4da08..c5129ac 100644 --- a/apps/app-frontend/src/assets/stylesheets/global.scss +++ b/apps/app-frontend/src/assets/stylesheets/global.scss @@ -83,6 +83,56 @@ html.rounded-window #app { background-color: var(--color-raised-bg); } +// Middle-click autoscroll (helpers/autoscroll.ts) +html.autoscrolling, +html.autoscrolling * { + cursor: all-scroll !important; +} + +.autoscroll-shield { + position: fixed; + inset: 0; + z-index: 2147483646; +} + +.autoscroll-indicator { + position: fixed; + z-index: 2147483647; + pointer-events: none; + width: 32px; + height: 32px; + border-radius: 50%; + color: var(--color-contrast); + background-color: var(--color-raised-bg); + // A ring instead of a border, which would shrink the space the icon has. + box-shadow: + 0 0 0 1px var(--color-button-bg), + 0 2px 8px rgba(0, 0, 0, 0.35); + + // Overrides the global `svg { width: 1em; height: 1em }`, which squeezed the + // icon into the top left corner. + svg { + display: block; + width: 100%; + height: 100%; + } + + path { + opacity: 0.45; + transition: + opacity 0.1s, + stroke 0.1s; + } + + &[data-dir*='n'] [data-dir='n'], + &[data-dir*='s'] [data-dir='s'], + &[data-dir*='e'] [data-dir='e'], + &[data-dir*='w'] [data-dir='w'] { + opacity: 1; + stroke: var(--color-brand); + } +} + * { box-sizing: border-box; } diff --git a/apps/app-frontend/src/helpers/autoscroll.ts b/apps/app-frontend/src/helpers/autoscroll.ts new file mode 100644 index 0000000..82e1790 --- /dev/null +++ b/apps/app-frontend/src/helpers/autoscroll.ts @@ -0,0 +1,290 @@ +// Middle-click autoscroll, as browsers on Windows do it. WebKitGTK has none: +// on Linux the middle button pastes instead. +// +// Press and release to keep scrolling until the next click, or press, drag and +// release to scroll only while the button is held. + +const DEAD_ZONE = 12 +// A press shorter than this, without leaving the dead zone, is a click that +// leaves autoscroll running. +const CLICK_MS = 300 + +const INTERACTIVE = + 'a, button, input, textarea, select, [contenteditable]:not([contenteditable="false"])' + +type Axes = { x: boolean; y: boolean } + +function overflowScrolls(overflow: string) { + return overflow === 'auto' || overflow === 'scroll' +} + +// Every ancestor that can scroll, innermost first. +function scrollersFrom(start: Element | null): Element[] { + const scrollers: Element[] = [] + for (let el = start; el && el !== document.documentElement; el = el.parentElement) { + const style = getComputedStyle(el) + if (overflowScrolls(style.overflowX) || overflowScrolls(style.overflowY)) scrollers.push(el) + } + return scrollers +} + +function scrollableAxes(scrollers: Element[]): Axes { + const axes = { x: false, y: false } + for (const el of scrollers) { + const style = getComputedStyle(el) + axes.x ||= overflowScrolls(style.overflowX) && el.scrollWidth > el.clientWidth + axes.y ||= overflowScrolls(style.overflowY) && el.scrollHeight > el.clientHeight + } + return axes +} + +// Scroll the innermost scroller that can still move this way, and hand the +// movement outwards once it reaches its end, as wheel scrolling does. Returns +// whether anything moved. +function scrollChained(scrollers: Element[], left: number, top: number): boolean { + let moved = false + for (const [delta, vertical] of [ + [left, false], + [top, true], + ] as const) { + if (!delta) continue + for (const el of scrollers) { + const before = vertical ? el.scrollTop : el.scrollLeft + el.scrollBy( + vertical ? { top: delta, behavior: 'instant' } : { left: delta, behavior: 'instant' }, + ) + if ((vertical ? el.scrollTop : el.scrollLeft) !== before) { + moved = true + break + } + } + } + return moved +} + +// WebKitGTK can leave the painted scroll position a few frames behind fast +// programmatic scrolling. Once the real position stops changing, as it does at +// either end, it never paints the last ones: the page looks stuck short of the +// end while hit testing already uses the real position. A one pixel round trip +// over two frames makes it paint again. +function repaintScrollPositions(scrollers: Element[]) { + for (const el of scrollers) { + for (const vertical of [true, false]) { + const range = vertical ? el.scrollHeight - el.clientHeight : el.scrollWidth - el.clientWidth + if (range <= 0) continue + const position = vertical ? el.scrollTop : el.scrollLeft + const away = position > 0 ? position - 1 : position + 1 + if (vertical) el.scrollTop = away + else el.scrollLeft = away + requestAnimationFrame(() => { + if (vertical) el.scrollTop = position + else el.scrollLeft = position + }) + } + } +} + +function createIndicator(x: number, y: number, axes: Axes): HTMLElement { + const chevrons = [ + axes.y ? '' : '', + axes.x ? '' : '', + ].join('') + const indicator = document.createElement('div') + indicator.className = 'autoscroll-indicator' + indicator.style.left = `${x - 16}px` + indicator.style.top = `${y - 16}px` + indicator.innerHTML = `${chevrons}` + document.body.appendChild(indicator) + return indicator +} + +// Covers the page while autoscroll runs. The pointer does not move while the +// page does, so without it whatever scrolls under the pointer would light up +// as hovered. +function createShield(): HTMLElement { + const shield = document.createElement('div') + shield.className = 'autoscroll-shield' + document.body.appendChild(shield) + return shield +} + +// Pixels per second for a pointer this far from where autoscroll started. +function speed(distance: number): number { + const past = Math.abs(distance) - DEAD_ZONE + if (past <= 0) return 0 + return Math.sign(distance) * Math.min(past ** 1.5 * 1.2, 8000) +} + +export function installAutoscroll(): () => void { + let active: { + origin: Element + scrollers: Element[] + axes: Axes + startX: number + startY: number + pointerX: number + pointerY: number + pressedAt: number + held: boolean + carryX: number + carryY: number + lastFrame: number + frame: number + stuckFrames: number + indicator: HTMLElement + shield: HTMLElement + } | null = null + // The mouseup, click and auxclick that belong to a press autoscroll consumed. + let swallowButtons = 0 + + function stop() { + if (!active) return + cancelAnimationFrame(active.frame) + active.indicator.remove() + active.shield.remove() + document.documentElement.classList.remove('autoscrolling') + repaintScrollPositions(active.scrollers) + active = null + } + + function tick(now: number) { + if (!active) return + const dt = Math.min(now - active.lastFrame, 100) / 1000 + active.lastFrame = now + + // The page may have re-rendered the element autoscroll started on. + if (!active.origin.isConnected) { + active.shield.style.pointerEvents = 'none' + const origin = document.elementFromPoint(active.startX, active.startY) + active.shield.style.pointerEvents = '' + if (origin) { + active.origin = origin + active.scrollers = scrollersFrom(origin) + } + } + + active.carryX += active.axes.x ? speed(active.pointerX - active.startX) * dt : 0 + active.carryY += active.axes.y ? speed(active.pointerY - active.startY) * dt : 0 + const left = Math.trunc(active.carryX) + const top = Math.trunc(active.carryY) + if (left || top) { + if (scrollChained(active.scrollers, left, top)) { + active.stuckFrames = 0 + } else if (++active.stuckFrames === 2) { + // Just ran into an end: make sure the last frames get painted. + repaintScrollPositions(active.scrollers) + } + active.carryX -= left + active.carryY -= top + } + active.frame = requestAnimationFrame(tick) + } + + function swallow(e: Event) { + e.preventDefault() + e.stopImmediatePropagation() + } + + function onMouseDown(e: MouseEvent) { + if (active) { + // Any press ends a running autoscroll and does nothing else. + swallow(e) + stop() + swallowButtons = 1 << e.button + return + } + swallowButtons = 0 + if (e.button !== 1 || !(e.target instanceof Element)) return + if (e.target.closest(INTERACTIVE)) return + + const scrollers = scrollersFrom(e.target) + const axes = scrollableAxes(scrollers) + if (!axes.x && !axes.y) return + + swallow(e) + swallowButtons = 1 << e.button + active = { + origin: e.target, + scrollers, + axes, + startX: e.clientX, + startY: e.clientY, + pointerX: e.clientX, + pointerY: e.clientY, + pressedAt: performance.now(), + held: false, + carryX: 0, + carryY: 0, + lastFrame: performance.now(), + frame: 0, + stuckFrames: 0, + shield: createShield(), + indicator: createIndicator(e.clientX, e.clientY, axes), + } + document.documentElement.classList.add('autoscrolling') + active.frame = requestAnimationFrame(tick) + } + + function onMouseMove(e: MouseEvent) { + if (!active) return + active.pointerX = e.clientX + active.pointerY = e.clientY + const dx = e.clientX - active.startX + const dy = e.clientY - active.startY + if (Math.abs(dx) > DEAD_ZONE || Math.abs(dy) > DEAD_ZONE) { + active.held = true + } + + // Light up the chevrons for the direction the page is moving in + const vertical = active.axes.y && Math.abs(dy) > DEAD_ZONE ? (dy < 0 ? 'n' : 's') : '' + const horizontal = active.axes.x && Math.abs(dx) > DEAD_ZONE ? (dx < 0 ? 'w' : 'e') : '' + active.indicator.dataset.dir = vertical + horizontal + } + + function onMouseUp(e: MouseEvent) { + if (!(swallowButtons & (1 << e.button))) return + swallow(e) + if ( + active && + e.button === 1 && + (active.held || performance.now() - active.pressedAt > CLICK_MS) + ) { + stop() + } + } + + function onClick(e: MouseEvent) { + if (!(swallowButtons & (1 << e.button))) return + swallow(e) + swallowButtons &= ~(1 << e.button) + } + + function onKeyDown(e: KeyboardEvent) { + if (!active) return + if (e.key === 'Escape') swallow(e) + stop() + } + + const listeners: [string, (e: never) => void][] = [ + ['mousedown', onMouseDown], + ['mousemove', onMouseMove], + ['mouseup', onMouseUp], + ['click', onClick], + ['auxclick', onClick], + ['keydown', onKeyDown], + ['wheel', stop], + // Capturing on window also sees every element losing focus; only the + // window itself losing it matters. + ['blur', (e: FocusEvent) => e.target === window && stop()], + ] + for (const [type, listener] of listeners) { + window.addEventListener(type, listener as EventListener, { capture: true }) + } + + return () => { + stop() + for (const [type, listener] of listeners) { + window.removeEventListener(type, listener as EventListener, { capture: true }) + } + } +}