mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 19:29:33 +00:00
Fix v3 Songs A–Z rail: reliable taps, precise drag, hittable size (#653)
* Fix v3 Songs A–Z rail: reliable taps, precise drag, hittable size Follow-up to #634. Three rail bugs reported on macOS + Windows (0.3.0, 2026-06-29 — =Scr4tch=, MajorMokoto): - Taps often did nothing ("clicked O, nothing happened"). pointerdown calls setPointerCapture, after which the browser retargets the follow-up click to the rail container, so the click handler's closest('.v3-azrail-letter') resolved null and a plain tap (no pointermove) had no other path. Drive the jump from pointerdown itself; reduce the click handler to keyboard activation only (e.detail === 0, Enter/Space). - A drag landed short of the release ("where you release isn't where you get sent"). Every letter crossed fired jumpToLetter with behavior:'smooth'; stacked smooth-scroll animations over the virtualized grid lagged and settled imprecisely. jumpToLetter now takes a smooth flag and scrolls instantly ('auto') while scrubbing, animating only discrete taps/keyboard jumps, so the grid tracks the finger and the release lands on the let-go letter. - The rail was too small at 1440p and didn't scale. Letters were a fixed .62rem glued at right:2px (~13px-tall target). They now scale with the viewport (clamp(.72rem, 1.4vh, 1.05rem)), sit off the edge with taller/wider equal-width hit targets and a hover/active highlight so the scrub target is visible. Keyboard arrow-nav and present-letter gating are unchanged. Tests: tests/js/v3_az_rail.test.js gains pointerdown-seek, keyboard-only click guard, and instant-vs-smooth assertions (809 JS tests; the 13 pre-existing unrelated failures are unchanged). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF * fix(v3): ignore non-primary buttons on A–Z rail pointerdown (PR #653 review) Right- or middle-clicking the A–Z rail (or a secondary multi-touch pointer) no longer triggers a seek; only the primary tap/drag scrubs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: byrongamatos <xasiklas@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
byrongamatos
parent
0d28886d46
commit
15fabb62aa
+48
-22
@@ -1867,7 +1867,11 @@
|
||||
}
|
||||
|
||||
let _jumpToken = 0;
|
||||
async function jumpToLetter(letter) {
|
||||
// `smooth` animates the scroll (a discrete tap / keyboard jump). A drag-scrub
|
||||
// passes `false` so each step snaps INSTANTLY: stacked smooth animations over
|
||||
// the windowed grid lag and settle imprecisely, which is why a drag used to
|
||||
// land somewhere other than the let-go letter.
|
||||
async function jumpToLetter(letter, smooth = true) {
|
||||
const grid = _gridEl(), sizer = _sizerEl(), main = document.getElementById('v3-main');
|
||||
if (!grid || !sizer || !main || state.view !== 'grid' || !letter) return;
|
||||
_setRailActive(letter);
|
||||
@@ -1890,7 +1894,7 @@
|
||||
const toolbar = document.getElementById('v3-songs-toolbar');
|
||||
const pad = (toolbar ? toolbar.offsetHeight : 0) + 12; // clear the sticky toolbar
|
||||
const top = Math.max(0, sizerTop + targetRow * rowH - pad);
|
||||
main.scrollTo({ top, behavior: 'smooth' });
|
||||
main.scrollTo({ top, behavior: smooth ? 'smooth' : 'auto' });
|
||||
requestWindowRender();
|
||||
}
|
||||
|
||||
@@ -1898,38 +1902,60 @@
|
||||
const rail = railEl();
|
||||
if (!rail || rail._bound) return;
|
||||
rail._bound = true;
|
||||
let dragging = false, moved = false, lastDrag = null;
|
||||
let dragging = false, lastDrag = null, railBtns = [];
|
||||
// Resolve the letter button under a viewport-Y from the per-drag cached
|
||||
// list (avoids a querySelectorAll per pointermove), clamping past the
|
||||
// top/bottom so a scrub off the ends still seeks the first/last letter.
|
||||
const letterAtY = (y) => {
|
||||
const els = rail.querySelectorAll('.v3-azrail-letter');
|
||||
if (!els.length) return null;
|
||||
for (const el of els) { const r = el.getBoundingClientRect(); if (y >= r.top && y <= r.bottom) return el; }
|
||||
return y < els[0].getBoundingClientRect().top ? els[0] : els[els.length - 1]; // clamp past ends
|
||||
if (!railBtns.length) return null;
|
||||
for (const el of railBtns) { const r = el.getBoundingClientRect(); if (y >= r.top && y <= r.bottom) return el; }
|
||||
return y < railBtns[0].getBoundingClientRect().top ? railBtns[0] : railBtns[railBtns.length - 1];
|
||||
};
|
||||
// Seek to the letter under `y`. `smooth` on the initial press (a tap);
|
||||
// instant during the scrub so the grid tracks the finger and RELEASE
|
||||
// lands exactly on the let-go letter.
|
||||
const seekToY = (y, smooth) => {
|
||||
const el = letterAtY(y);
|
||||
if (!el || el.disabled) return;
|
||||
const L = el.getAttribute('data-letter');
|
||||
_showBubble(L);
|
||||
if (L !== lastDrag) { lastDrag = L; jumpToLetter(L, smooth); }
|
||||
};
|
||||
rail.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.v3-azrail-letter');
|
||||
if (!btn || btn.disabled) return;
|
||||
if (moved) { moved = false; return; } // a drag already handled it
|
||||
jumpToLetter(btn.getAttribute('data-letter'));
|
||||
});
|
||||
rail.addEventListener('pointerdown', (e) => {
|
||||
if (e.button !== 0 || e.isPrimary === false) return; // primary tap only; ignore right/middle-click + secondary touches
|
||||
const btn = e.target.closest('.v3-azrail-letter');
|
||||
if (!btn) return;
|
||||
dragging = true; moved = false; lastDrag = null;
|
||||
railBtns = [...rail.querySelectorAll('.v3-azrail-letter')];
|
||||
dragging = true; lastDrag = null;
|
||||
// Capture so a vertical scrub keeps seeking even if the pointer drifts
|
||||
// off the thin rail horizontally.
|
||||
try { rail.setPointerCapture(e.pointerId); } catch (_) { /* */ }
|
||||
_showBubble(btn.getAttribute('data-letter'));
|
||||
// Drive the jump from here, NOT from the click event: pointer capture
|
||||
// retargets the follow-up click to the rail (never a letter), so a
|
||||
// captured tap's click can't resolve a letter and used to no-op
|
||||
// ("clicked O, nothing happened"). preventDefault() suppresses the
|
||||
// text-selection / focus-scroll default; we re-focus below for kbd.
|
||||
e.preventDefault();
|
||||
try { btn.focus({ preventScroll: true }); } catch (_) { /* */ }
|
||||
seekToY(e.clientY, true); // jump on press → a tap lands immediately
|
||||
});
|
||||
rail.addEventListener('pointermove', (e) => {
|
||||
if (!dragging) return;
|
||||
const el = letterAtY(e.clientY);
|
||||
if (!el || el.disabled) return;
|
||||
moved = true;
|
||||
const L = el.getAttribute('data-letter');
|
||||
_showBubble(L);
|
||||
if (L !== lastDrag) { lastDrag = L; jumpToLetter(L); } // only on change
|
||||
seekToY(e.clientY, false); // instant tracking during the scrub
|
||||
});
|
||||
const end = () => { dragging = false; _hideBubble(); };
|
||||
const end = () => { dragging = false; railBtns = []; _hideBubble(); };
|
||||
rail.addEventListener('pointerup', end);
|
||||
rail.addEventListener('pointercancel', end);
|
||||
// Keyboard activation only. A pointer-driven click (detail >= 1) is
|
||||
// retargeted to the rail by pointer capture and can't resolve a letter,
|
||||
// so the pointer path owns taps; act here only on Enter/Space, whose
|
||||
// synthesized click has detail === 0.
|
||||
rail.addEventListener('click', (e) => {
|
||||
if (e.detail !== 0) return;
|
||||
const btn = e.target.closest('.v3-azrail-letter');
|
||||
if (!btn || btn.disabled) return;
|
||||
jumpToLetter(btn.getAttribute('data-letter'));
|
||||
});
|
||||
rail.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
|
||||
const btns = [...rail.querySelectorAll('.v3-azrail-letter:not([disabled])')];
|
||||
|
||||
+14
-8
@@ -1205,14 +1205,14 @@ html.fb-immersive #v3-main > .screen.active {
|
||||
centered. Shown only for the grid view + alphabetical (artist/title) sorts. */
|
||||
.v3-azrail {
|
||||
position: fixed;
|
||||
right: 2px;
|
||||
right: 4px; /* off the very edge so letters aren't clipped */
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 25;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
max-height: 84vh;
|
||||
align-items: stretch; /* equal-width buttons → one wide, even hit column */
|
||||
max-height: 92vh;
|
||||
padding: 4px 1px;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
@@ -1224,17 +1224,23 @@ html.fb-immersive #v3-main > .screen.active {
|
||||
background: none;
|
||||
border: 0;
|
||||
color: #94a3b8; /* fb-textDim */
|
||||
font-size: .62rem;
|
||||
/* Scale with viewport height so the 27-letter rail grows on tall / hi-res
|
||||
displays (a fixed size looked tiny at 1440p) while still fitting 27 rows
|
||||
within max-height on short screens. */
|
||||
font-size: clamp(.72rem, 1.4vh, 1.05rem);
|
||||
font-weight: 700;
|
||||
line-height: 1.05;
|
||||
padding: 1px 4px;
|
||||
line-height: 1.04;
|
||||
/* Vertical padding fattens the tap target (was ~13px tall → easy to miss). */
|
||||
padding: clamp(2px, .55vh, 6px) 9px;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
.v3-azrail-letter:hover:not([disabled]),
|
||||
.v3-azrail-letter.is-active {
|
||||
color: #0ea5e9; /* fb-primary */
|
||||
background: rgba(14, 165, 233, .15); /* visible target under the scrub */
|
||||
}
|
||||
.v3-azrail-letter:focus-visible {
|
||||
outline: 2px solid #38bdf8; /* fb-primaryHi */
|
||||
@@ -1247,7 +1253,7 @@ html.fb-immersive #v3-main > .screen.active {
|
||||
/* Drag indicator bubble (Android fast-scroll pattern). */
|
||||
.v3-azbubble {
|
||||
position: fixed;
|
||||
right: 2.6rem;
|
||||
right: 2.9rem; /* clear the (now wider) rail */
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 26;
|
||||
@@ -1269,7 +1275,7 @@ html.fb-immersive #v3-main > .screen.active {
|
||||
/* Coarse-pointer / short viewports: the 27-letter rail can crowd a phone edge.
|
||||
Tighten it; a collapse-to-anchors pass is a follow-up. */
|
||||
@media (max-height: 640px) {
|
||||
.v3-azrail-letter { font-size: .55rem; padding: 0 4px; }
|
||||
.v3-azrail-letter { font-size: clamp(.5rem, 1.3vh, .62rem); padding: 0 7px; }
|
||||
}
|
||||
|
||||
/* — Practice-aware library home: repertoire meter + "Keep practicing" shelf — */
|
||||
|
||||
Reference in New Issue
Block a user