diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-01 22:19:18 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-01 22:19:18 +0300 |
| commit | b9807cb96db2c32df592dde5f3b2b70b8c4c212c (patch) | |
| tree | 0f8da2f75da171f47cb78369a7b32e682fd431a3 | |
| parent | 9058517557a6c47898b792a9b174c970c4d7d754 (diff) | |
Handle scroll-driven post selection for task b
| -rw-r--r-- | integrationtests/integration_test.go | 3 | ||||
| -rw-r--r-- | internal/generator/templates/shared/shared.js | 93 |
2 files changed, 85 insertions, 11 deletions
diff --git a/integrationtests/integration_test.go b/integrationtests/integration_test.go index cb1553f..f53688d 100644 --- a/integrationtests/integration_test.go +++ b/integrationtests/integration_test.go @@ -374,6 +374,9 @@ func TestKeyboardNavJS(t *testing.T) { assertContains(t, sharedCSS, `.post-active`, "shared.css .post-active rule") sharedJS := readFile(t, filepath.Join(outputDir, "shared.js")) assertContains(t, sharedJS, `playNavSound`, "shared.js playNavSound function") + assertContains(t, sharedJS, `setupScrollDrivenSelection`, "shared.js scroll-driven selection") + assertContains(t, sharedJS, `activeIndexForVisibleRegion(sc)`, "shared.js visible center selection") + assertContains(t, sharedJS, `SCROLL_SELECTION_SOUND_GAP`, "shared.js throttles scroll selection sound") // Final shortcut mapping: p = ambient playback start/pause, f = flash. assertContains(t, sharedJS, "case 'p':", "shared.js p key handler") diff --git a/internal/generator/templates/shared/shared.js b/internal/generator/templates/shared/shared.js index 5a287c7..09de580 100644 --- a/internal/generator/templates/shared/shared.js +++ b/internal/generator/templates/shared/shared.js @@ -1283,15 +1283,28 @@ // === KEYBOARD NAVIGATION === // j / ArrowDown → next post k / ArrowUp → previous post // h / ArrowLeft → previous page l / ArrowRight → next page - // PageUp/PageDown → scroll the post list; re-highlight post at top of visible area + // PageUp/PageDown → scroll the post list; re-highlight post nearest visible center // Enter / click post → expand modal Esc → close modal const posts = document.querySelectorAll('.post'); let currentIndex = posts.length > 0 ? 0 : -1; + var scrollSelectionFrame = null; + var scrollSelectionSoundAt = 0; + var ignoreScrollSelectionUntil = 0; + var SCROLL_SELECTION_SOUND_GAP = 180; + var PROGRAMMATIC_SCROLL_SUPPRESS_MS = 650; var prevPageURL = (typeof window !== "undefined") ? (window.snonuxPrevPageURL || null) : null; var nextPageURL = (typeof window !== "undefined") ? (window.snonuxNextPageURL || null) : null; if (currentIndex >= 0) selectPost(0); + function postListScroller() { + return document.getElementById('post-content'); + } + + function markProgrammaticPostScroll() { + ignoreScrollSelectionUntil = Date.now() + PROGRAMMATIC_SCROLL_SUPPRESS_MS; + } + function setActiveHighlight(index, playSound, scrollIntoView) { if (posts.length === 0) return; var prevIdx = currentIndex; @@ -1310,6 +1323,7 @@ ghost.classList.add('sno-afterimage-active'); } if (scrollIntoView) { + markProgrammaticPostScroll(); posts[currentIndex].scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } if (playSound) playNavSound(); @@ -1327,32 +1341,89 @@ if (window.snonuxNavEffect) window.snonuxNavEffect(); } - /** Pick the post that should be active for the current viewport (anchor near top of visible area). */ + /** Pick the post that should be active for the current viewport (nearest the visible center). */ function activeIndexForVisibleRegion(sc) { if (posts.length === 0) return -1; - var scrTop, scrBot, anchorY; + var scrTop, scrBot, centerY; if (sc) { var scr = sc.getBoundingClientRect(); scrTop = scr.top; scrBot = scr.bottom; - anchorY = scr.top + Math.min(scr.height * 0.18, 100); + centerY = scr.top + (scr.height / 2); } else { scrTop = 0; scrBot = window.innerHeight; - anchorY = window.innerHeight * 0.15; + centerY = window.innerHeight / 2; } - var i, pr; + var i, pr, visTop, visBot, distance; + var bestIndex = -1; + var bestDistance = Infinity; for (i = 0; i < posts.length; i++) { pr = posts[i].getBoundingClientRect(); - if (pr.top <= anchorY && anchorY < pr.bottom) return i; + if (pr.top <= centerY && centerY < pr.bottom) return i; } for (i = 0; i < posts.length; i++) { pr = posts[i].getBoundingClientRect(); - if (pr.bottom > scrTop && pr.top < scrBot) return i; + if (pr.bottom <= scrTop || pr.top >= scrBot) continue; + visTop = Math.max(pr.top, scrTop); + visBot = Math.min(pr.bottom, scrBot); + distance = Math.abs(((visTop + visBot) / 2) - centerY); + if (distance < bestDistance) { + bestDistance = distance; + bestIndex = i; + } + } + if (bestIndex >= 0) return bestIndex; + for (i = 0; i < posts.length; i++) { + pr = posts[i].getBoundingClientRect(); + distance = Math.abs(((pr.top + pr.bottom) / 2) - centerY); + if (distance < bestDistance) { + bestDistance = distance; + bestIndex = i; + } + } + return bestIndex; + } + + function updateActiveFromUserScroll(sc) { + if (Date.now() < ignoreScrollSelectionUntil) return; + var nextIndex = activeIndexForVisibleRegion(sc); + if (nextIndex < 0 || nextIndex === currentIndex) return; + + var prevIndex = currentIndex; + var now = Date.now(); + var playSound = (now - scrollSelectionSoundAt) >= SCROLL_SELECTION_SOUND_GAP; + setActiveHighlight(nextIndex, playSound, false); + if (playSound) { + scrollSelectionSoundAt = now; + if (window.snonuxScrollEffect) { + var direction = nextIndex > prevIndex ? 'down' : 'up'; + window.snonuxScrollEffect(direction); + } } - return posts.length - 1; } + function scheduleScrollDrivenSelection(sc) { + if (Date.now() < ignoreScrollSelectionUntil || scrollSelectionFrame !== null) return; + scrollSelectionFrame = requestAnimationFrame(function() { + scrollSelectionFrame = null; + updateActiveFromUserScroll(sc); + }); + } + + (function setupScrollDrivenSelection() { + var sc = postListScroller(); + if (sc) { + sc.addEventListener('scroll', function() { + scheduleScrollDrivenSelection(sc); + }, { passive: true }); + } else { + window.addEventListener('scroll', function() { + scheduleScrollDrivenSelection(null); + }, { passive: true }); + } + })(); + function playNavSound() { try { var n = SNONUX_SOUNDS.nav; @@ -1812,9 +1883,10 @@ switch (e.key) { case 'PageUp': case 'PageDown': { - var sc = document.getElementById('post-content'); + var sc = postListScroller(); var step = (sc && sc.clientHeight) ? sc.clientHeight : window.innerHeight; var dy = (e.key === 'PageUp') ? -step : step; + markProgrammaticPostScroll(); if (sc) { sc.scrollTop += dy; } else { @@ -2030,4 +2102,3 @@ snonuxSwitchTheme(sel.value); }); })(); - |
