401 lines
13 KiB
Diff
401 lines
13 KiB
Diff
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
|
|
From: Modrinth Enhanced <patches@modrinth-enhanced.invalid>
|
|
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 dcae1f1..e18904b 100644
|
|
--- a/apps/app-frontend/src/App.vue
|
|
+++ b/apps/app-frontend/src/App.vue
|
|
@@ -126,6 +126,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'
|
|
@@ -603,11 +604,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 ? '<path data-dir="n" d="M11 10l5-5 5 5"/><path data-dir="s" d="M11 22l5 5 5-5"/>' : '',
|
|
+ axes.x ? '<path data-dir="w" d="M10 11l-5 5 5 5"/><path data-dir="e" d="M22 11l5 5-5 5"/>' : '',
|
|
+ ].join('')
|
|
+ const indicator = document.createElement('div')
|
|
+ indicator.className = 'autoscroll-indicator'
|
|
+ indicator.style.left = `${x - 16}px`
|
|
+ indicator.style.top = `${y - 16}px`
|
|
+ indicator.innerHTML = `<svg viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="16" cy="16" r="2" fill="currentColor" stroke="none"/>${chevrons}</svg>`
|
|
+ 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 })
|
|
+ }
|
|
+ }
|
|
+}
|