Make light wheel scrolls on the tile row commit to the next card

Client feedback: a light/small scroll snapped back to the current card
instead of advancing - native scroll-snap always settles on whichever
card is nearest, and a light nudge rarely crosses the halfway point.

Each wheel burst now tracks its starting card index and net deltaY.
On settle, if the nearest card by position is still the start card but
there was clear directional movement (>4px net), the row advances one
card in that direction instead of springing back - so even a light
wheel tick pages forward. Larger scrolls are unaffected: if the actual
scroll position already lands closer to a farther card, it jumps
straight there (no artificial one-card-per-burst cap), and a burst that
nets out near zero (e.g. wiggling back and forth) correctly stays put.
The settle target is computed the same way the old drag-to-scroll
feature centered cards, and re-enables scroll-snap only after the
settling scrollTo animation would have finished, so it doesn't fight
the smooth scroll.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Maaxxs 2026-07-20 22:30:16 +02:00
parent eb59e9c1c9
commit eba15ef52b
3 changed files with 82 additions and 30 deletions

View file

@ -351,7 +351,55 @@ export default function ModelPage() {
useEffect(() => { useEffect(() => {
const el = scrollerRef.current; const el = scrollerRef.current;
if (layout === 'grid' || !el) return undefined; if (layout === 'grid' || !el) return undefined;
let snapTimer = 0; let idleTimer = 0;
let reenableTimer = 0;
let burstActive = false;
let burstStartIndex = 0;
let burstNet = 0;
const cardCenters = () => [...el.children].map((card) => card.offsetLeft + card.offsetWidth / 2);
const nearestIndexAt = (centers, mid) => {
let best = 0;
let bestDist = Infinity;
centers.forEach((center, i) => {
const dist = Math.abs(center - mid);
if (dist < bestDist) {
bestDist = dist;
best = i;
}
});
return best;
};
// Beim Einrasten nach einer Wheel-Boe NICHT einfach zur naechstgelegenen Karte
// (= oft die Startkarte, wenn nur leicht gescrollt wurde - fuehlte sich wie ein
// Zurueckspringen an, Kundenfeedback). Stattdessen: landet die naechstgelegene
// Karte trotz erkennbarer Scroll-Richtung wieder auf der Startkarte, wird
// trotzdem ein Schritt in diese Richtung erzwungen - auch ein leichter Wisch
// blaettert also mindestens eine Karte weiter. Bei weiterem Scrollen (die
// naechstgelegene Karte liegt schon woanders) gilt weiterhin ganz normal die
// naechstgelegene Karte, auch wenn das mehrere Karten weiter ist.
const settleBurst = () => {
burstActive = false;
const centers = cardCenters();
if (!centers.length) return;
const mid = el.scrollLeft + el.clientWidth / 2;
const nearestIdx = nearestIndexAt(centers, mid);
let targetIdx = nearestIdx;
if (nearestIdx === burstStartIndex && Math.abs(burstNet) > 4) {
targetIdx =
burstNet > 0 ? Math.min(burstStartIndex + 1, centers.length - 1) : Math.max(burstStartIndex - 1, 0);
}
const maxScroll = el.scrollWidth - el.clientWidth;
const target = Math.max(0, Math.min(centers[targetIdx] - el.clientWidth / 2, maxScroll));
el.scrollTo({ left: target, behavior: 'smooth' });
// Snap erst nach der weichen Fahrt wieder aktivieren, sonst kaempft sie
// gegen unser eigenes scrollTo an.
clearTimeout(reenableTimer);
reenableTimer = setTimeout(() => {
el.style.scrollSnapType = '';
}, 450);
};
const onWheel = (e) => { const onWheel = (e) => {
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) return; if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) return;
@ -362,22 +410,26 @@ export default function ModelPage() {
if ((e.deltaY < 0 && atStart) || (e.deltaY > 0 && atEnd)) return; if ((e.deltaY < 0 && atStart) || (e.deltaY > 0 && atEnd)) return;
e.preventDefault(); e.preventDefault();
// Snap waehrend der Wheel-Boe aussetzen (wie beim frueheren Ziehen), sonst if (!burstActive) {
// kaempft scroll-snap gegen schnell aufeinanderfolgende Wheel-Ticks. Der burstActive = true;
// Nachlauf-Timer wird bei jedem Tick verlaengert, rastet also erst kurz burstNet = 0;
// nach dem letzten Tick der Boe wieder ein. burstStartIndex = nearestIndexAt(cardCenters(), el.scrollLeft + el.clientWidth / 2);
}
burstNet += e.deltaY;
// Snap waehrend der Wheel-Boe aussetzen, sonst kaempft sie gegen schnell
// aufeinanderfolgende Wheel-Ticks. Der Nachlauf-Timer wird bei jedem Tick
// verlaengert, die Boe gilt also erst kurz nach dem letzten Tick als beendet.
el.style.scrollSnapType = 'none'; el.style.scrollSnapType = 'none';
el.scrollLeft += e.deltaY; el.scrollLeft += e.deltaY;
clearTimeout(snapTimer); clearTimeout(idleTimer);
snapTimer = setTimeout(() => { idleTimer = setTimeout(settleBurst, 150);
el.style.scrollSnapType = '';
}, 150);
}; };
el.addEventListener('wheel', onWheel, { passive: false }); el.addEventListener('wheel', onWheel, { passive: false });
return () => { return () => {
el.removeEventListener('wheel', onWheel); el.removeEventListener('wheel', onWheel);
clearTimeout(snapTimer); clearTimeout(idleTimer);
clearTimeout(reenableTimer);
}; };
}, [layout, data]); }, [layout, data]);

File diff suppressed because one or more lines are too long

View file

@ -43,7 +43,7 @@
"sameAs": ["https://www.youtube.com/@hifiplanet2812"] "sameAs": ["https://www.youtube.com/@hifiplanet2812"]
} }
</script> </script>
<script type="module" crossorigin src="/assets/index-3Dk2mE1-.js"></script> <script type="module" crossorigin src="/assets/index-D-bbfDHb.js"></script>
<link rel="modulepreload" crossorigin href="/assets/lucide-icons-CnpfS6R8.js"> <link rel="modulepreload" crossorigin href="/assets/lucide-icons-CnpfS6R8.js">
<link rel="stylesheet" crossorigin href="/assets/index-9666bnLb.css"> <link rel="stylesheet" crossorigin href="/assets/index-9666bnLb.css">
</head> </head>