fix(ui): overlay scrollbars, resizer hit-test, and Linux mini wheel (#255)

Add OverlayScrollArea with shared thumb metrics and drag handling; size the
thumb against the rail track height so panel insets cannot push it past the
visible rail. Route scroll uses a stable viewport id; Genres restores scroll
and infinite-scroll observation against that viewport (merged with upstream
virtualized genres list and lazy routes behind Suspense).

Suppress queue resizer activation when the pointer targets the main-route
overlay scrollbar; disable the resizer while dragging the thumb and use a
grabbing cursor on the body.

Apply WebKitGTK smooth wheel to the mini webview as well as main; the mini
window reapplies the persisted setting after auth store hydration.
This commit is contained in:
cucadmuh
2026-04-21 23:44:28 +03:00
committed by GitHub
parent 2318f9e07a
commit b61c168430
13 changed files with 538 additions and 130 deletions
+12 -7
View File
@@ -7,7 +7,7 @@ import {
Tags, type LucideIcon,
} from 'lucide-react';
import { getGenres, SubsonicGenre } from '../api/subsonic';
import { useAuthStore } from '../store/authStore';
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
function getGenreIcon(name: string): LucideIcon {
const n = name.toLowerCase();
@@ -59,7 +59,6 @@ export default function Genres() {
const saved = sessionStorage.getItem(VISIBLE_KEY);
return saved ? Math.max(PAGE_SIZE, parseInt(saved, 10)) : PAGE_SIZE;
});
const containerRef = useRef<HTMLDivElement>(null);
const observerTarget = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -83,9 +82,13 @@ export default function Genres() {
// paint of the page.
useEffect(() => {
if (!hasMore) return;
const root = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID);
const observer = new IntersectionObserver(
entries => { if (entries[0].isIntersecting) setVisibleCount(c => c + PAGE_SIZE); },
{ rootMargin: '1500px' },
{
root: root instanceof HTMLElement ? root : null,
rootMargin: '1500px',
},
);
if (observerTarget.current) observer.observe(observerTarget.current);
return () => observer.disconnect();
@@ -100,20 +103,22 @@ export default function Genres() {
sessionStorage.removeItem(SCROLL_KEY);
sessionStorage.removeItem(VISIBLE_KEY);
requestAnimationFrame(() => {
if (containerRef.current) containerRef.current.scrollTop = pos;
const el = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID);
if (el) el.scrollTop = pos;
});
}, [loading, genres.length]);
const handleGenreClick = (genreValue: string) => {
if (containerRef.current) {
sessionStorage.setItem(SCROLL_KEY, String(containerRef.current.scrollTop));
const el = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID);
if (el) {
sessionStorage.setItem(SCROLL_KEY, String(el.scrollTop));
sessionStorage.setItem(VISIBLE_KEY, String(visibleCount));
}
navigate(`/genres/${encodeURIComponent(genreValue)}`);
};
return (
<div ref={containerRef} className="content-body animate-fade-in">
<div className="content-body animate-fade-in">
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('genres.title')}</h1>
{!loading && genres.length > 0 && (