feat: rounded window corners and middle-click autoscroll
This commit is contained in:
1 parent
46d7c792be
commit
109171e355
4 files changed
+444
No files matched your search
@@ -20,6 +20,8 @@ works.
|
||||
| `0005-Make-the-sidebars-foldable` | A switch for the Modrinth Servers button, a news section that folds away, and a title bar button that folds the right sidebar away. |
|
||||
| `0006-Add-Ely.by-accounts` | Sign in with Ely.by, launched through authlib-injector. |
|
||||
| `0007-Sign-in-to-Microsoft-in-the-...` | Microsoft sign-in happens in your own browser instead of a webview, so your password manager works. |
|
||||
| `0008-Round-the-window-corners-on-Linux` | The undecorated window gets rounded corners on Linux. |
|
||||
| `0009-Scroll-with-the-middle-mouse-button` | Middle-click autoscroll on Linux and macOS, as browsers do it on Windows. |
|
||||
|
||||
### Offline accounts
|
||||
|
||||
@@ -77,6 +79,16 @@ None of the three reach Modrinth. Preference syncing maps a fixed list of named
|
||||
directions and these are not in it, so they are neither sent to your Modrinth account nor
|
||||
overwritten by another device.
|
||||
|
||||
### Window
|
||||
|
||||
On Linux the window has rounded corners while it floats. Maximized, fullscreen or with native
|
||||
decorations turned on, it is square as before. The window is created transparent for this, which
|
||||
needs a compositor; without one the corners show black.
|
||||
|
||||
A click with the middle mouse button on anything that scrolls starts autoscroll: press and release
|
||||
to scroll until the next click, or hold and drag to scroll until you let go. Links and text fields
|
||||
keep their middle-click. Windows is left alone, since WebView2 autoscrolls by itself.
|
||||
|
||||
### Modrinth+
|
||||
|
||||
Nothing in the app is gated behind Modrinth+. In upstream it decides whether the ad slot, the
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
|
||||
From: Modrinth Enhanced <patches@modrinth-enhanced.invalid>
|
||||
Date: Mon, 14 Sep 2026 19:15:01 +0200
|
||||
Subject: [PATCH] Round the window corners on Linux
|
||||
|
||||
---
|
||||
apps/app-frontend/src/App.vue | 10 +++++++++
|
||||
.../src/assets/stylesheets/global.scss | 17 ++++++++++++++
|
||||
apps/app-frontend/src/main.js | 5 +++++
|
||||
apps/app/tauri.linux.conf.json | 22 ++++++++++++++++++-
|
||||
4 files changed, 53 insertions(+), 1 deletion(-)
|
||||
|
||||
diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue
|
||||
index a9e3945..5a32c65 100644
|
||||
--- a/apps/app-frontend/src/App.vue
|
||||
+++ b/apps/app-frontend/src/App.vue
|
||||
@@ -529,6 +529,16 @@ watch([os, isFullscreen], ([osName, fullscreen]) => {
|
||||
document.documentElement.classList.toggle('mac-traffic-lights', osName === 'MacOS' && !fullscreen)
|
||||
})
|
||||
|
||||
+watch(
|
||||
+ [os, isMaximized, isFullscreen, () => appSettings.nativeDecorations],
|
||||
+ ([osName, maximized, fullscreen, nativeDecorations]) => {
|
||||
+ document.documentElement.classList.toggle(
|
||||
+ 'rounded-window',
|
||||
+ osName === 'Linux' && !nativeDecorations && !maximized && !fullscreen,
|
||||
+ )
|
||||
+ },
|
||||
+)
|
||||
+
|
||||
const authUnreachableDebug = useDebugLogger('AuthReachableChecker')
|
||||
const authServerQuery = useQuery({
|
||||
queryKey: ['authServerReachability'],
|
||||
diff --git a/apps/app-frontend/src/assets/stylesheets/global.scss b/apps/app-frontend/src/assets/stylesheets/global.scss
|
||||
index a9fa554..ba4da08 100644
|
||||
--- a/apps/app-frontend/src/assets/stylesheets/global.scss
|
||||
+++ b/apps/app-frontend/src/assets/stylesheets/global.scss
|
||||
@@ -66,6 +66,23 @@ body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
+// An undecorated window on Linux is transparent (tauri.linux.conf.json), so the
|
||||
+// page paints its own background and cuts the corners off. clip-path rather
|
||||
+// than overflow, because overflow does not clip fixed-position modals.
|
||||
+html.rounded-window,
|
||||
+html.rounded-window body {
|
||||
+ background: transparent;
|
||||
+}
|
||||
+
|
||||
+html.rounded-window body {
|
||||
+ clip-path: inset(0 round 10px);
|
||||
+}
|
||||
+
|
||||
+html.rounded-window #app {
|
||||
+ height: 100%;
|
||||
+ background-color: var(--color-raised-bg);
|
||||
+}
|
||||
+
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
diff --git a/apps/app-frontend/src/main.js b/apps/app-frontend/src/main.js
|
||||
index 9607cfb..e7991ef 100644
|
||||
--- a/apps/app-frontend/src/main.js
|
||||
+++ b/apps/app-frontend/src/main.js
|
||||
@@ -13,6 +13,11 @@ import i18nDebugPlugin from '@/plugins/i18n-debug'
|
||||
import router from '@/routes'
|
||||
|
||||
debugStartup('Frontend entry module evaluated')
|
||||
+// Round the corners from the first frame on; App.vue squares them again once it
|
||||
+// knows the window is maximized or natively decorated.
|
||||
+if (navigator.userAgent.includes('Linux')) {
|
||||
+ document.documentElement.classList.add('rounded-window')
|
||||
+}
|
||||
const app = createApp(App)
|
||||
setupErrorReporting(app, router)
|
||||
|
||||
diff --git a/apps/app/tauri.linux.conf.json b/apps/app/tauri.linux.conf.json
|
||||
index 81a368c..68fadf3 100644
|
||||
--- a/apps/app/tauri.linux.conf.json
|
||||
+++ b/apps/app/tauri.linux.conf.json
|
||||
@@ -1,3 +1,23 @@
|
||||
{
|
||||
- "mainBinaryName": "ModrinthEnhanced"
|
||||
+ "mainBinaryName": "ModrinthEnhanced",
|
||||
+ "app": {
|
||||
+ "windows": [
|
||||
+ {
|
||||
+ "titleBarStyle": "Overlay",
|
||||
+ "hiddenTitle": true,
|
||||
+ "fullscreen": false,
|
||||
+ "height": 800,
|
||||
+ "resizable": true,
|
||||
+ "title": "Modrinth Enhanced",
|
||||
+ "label": "main",
|
||||
+ "width": 1280,
|
||||
+ "minHeight": 700,
|
||||
+ "minWidth": 1100,
|
||||
+ "visible": false,
|
||||
+ "zoomHotkeysEnabled": false,
|
||||
+ "decorations": false,
|
||||
+ "transparent": true
|
||||
+ }
|
||||
+ ]
|
||||
+ }
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
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 | 44 ++++
|
||||
apps/app-frontend/src/helpers/autoscroll.ts | 201 ++++++++++++++++++
|
||||
3 files changed, 253 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 5a32c65..469ae08 100644
|
||||
--- a/apps/app-frontend/src/App.vue
|
||||
+++ b/apps/app-frontend/src/App.vue
|
||||
@@ -123,6 +123,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'
|
||||
@@ -589,11 +590,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..442f181 100644
|
||||
--- a/apps/app-frontend/src/assets/stylesheets/global.scss
|
||||
+++ b/apps/app-frontend/src/assets/stylesheets/global.scss
|
||||
@@ -83,6 +83,50 @@ 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-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..1faef8e
|
||||
--- /dev/null
|
||||
+++ b/apps/app-frontend/src/helpers/autoscroll.ts
|
||||
@@ -0,0 +1,201 @@
|
||||
+// 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 scrollableAxes(el: Element): Axes {
|
||||
+ const style = getComputedStyle(el)
|
||||
+ const scrolls = (overflow: string) => overflow === 'auto' || overflow === 'scroll'
|
||||
+ return {
|
||||
+ x: scrolls(style.overflowX) && el.scrollWidth > el.clientWidth,
|
||||
+ y: scrolls(style.overflowY) && el.scrollHeight > el.clientHeight,
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+function findScrollable(start: Element | null): { el: Element; axes: Axes } | null {
|
||||
+ for (let el = start; el && el !== document.documentElement; el = el.parentElement) {
|
||||
+ const axes = scrollableAxes(el)
|
||||
+ if (axes.x || axes.y) return { el, axes }
|
||||
+ }
|
||||
+ return null
|
||||
+}
|
||||
+
|
||||
+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
|
||||
+}
|
||||
+
|
||||
+// 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: {
|
||||
+ el: Element
|
||||
+ axes: Axes
|
||||
+ startX: number
|
||||
+ startY: number
|
||||
+ pointerX: number
|
||||
+ pointerY: number
|
||||
+ pressedAt: number
|
||||
+ held: boolean
|
||||
+ carryX: number
|
||||
+ carryY: number
|
||||
+ lastFrame: number
|
||||
+ frame: number
|
||||
+ indicator: 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()
|
||||
+ document.documentElement.classList.remove('autoscrolling')
|
||||
+ active = null
|
||||
+ }
|
||||
+
|
||||
+ function tick(now: number) {
|
||||
+ if (!active) return
|
||||
+ const dt = Math.min(now - active.lastFrame, 100) / 1000
|
||||
+ active.lastFrame = now
|
||||
+
|
||||
+ 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) {
|
||||
+ active.el.scrollBy({ left, top, behavior: 'instant' })
|
||||
+ 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 found = findScrollable(e.target)
|
||||
+ if (!found) return
|
||||
+
|
||||
+ swallow(e)
|
||||
+ swallowButtons = 1 << e.button
|
||||
+ active = {
|
||||
+ ...found,
|
||||
+ 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,
|
||||
+ indicator: createIndicator(e.clientX, e.clientY, found.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 })
|
||||
+ }
|
||||
+ }
|
||||
+}
|
||||
@@ -84,6 +84,27 @@ check "the news section can be collapsed" \
|
||||
check "the right sidebar has a fold button" \
|
||||
contains "$WORKTREE/apps/app-frontend/src/App.vue" 'setSidebarCollapsed(sidebarToggled)'
|
||||
|
||||
# tauri.linux.conf.json replaces the whole window list, so an upstream change to
|
||||
# the main window would otherwise silently not reach Linux.
|
||||
same_linux_window() {
|
||||
node -e '
|
||||
const fs = require("fs")
|
||||
const [base, linux] = process.argv.slice(1).map((p) => JSON.parse(fs.readFileSync(p, "utf8")).app.windows[0])
|
||||
delete linux.transparent
|
||||
const sorted = (o) => JSON.stringify(o, Object.keys(o).sort())
|
||||
process.exit(sorted(base) === sorted(linux) ? 0 : 1)
|
||||
' "$WORKTREE/apps/app/tauri.conf.json" "$WORKTREE/apps/app/tauri.linux.conf.json"
|
||||
}
|
||||
|
||||
log "Window"
|
||||
check "the Linux window is transparent" \
|
||||
contains "$WORKTREE/apps/app/tauri.linux.conf.json" '"transparent": true'
|
||||
check "the Linux window otherwise matches upstream" same_linux_window
|
||||
check "its corners are rounded" \
|
||||
contains "$WORKTREE/apps/app-frontend/src/App.vue" "'rounded-window'"
|
||||
check "the middle button autoscrolls" \
|
||||
contains "$WORKTREE/apps/app-frontend/src/App.vue" 'installAutoscroll()'
|
||||
|
||||
log "No advertising or upsells"
|
||||
check "no Modrinth+ upsell in the app" \
|
||||
missing "$WORKTREE/apps/app-frontend/src/App.vue" "modrinth.plus"
|
||||
|
||||
Reference in new issue
Block a user