From 2f254d1a636791e54c43c662df1455eb417646fa Mon Sep 17 00:00:00 2001 From: LennarX Date: Sun, 19 Jul 2026 22:44:00 +0200 Subject: [PATCH] feat: flow the TOC rail across visible sections Replace the single-active scrollspy with a continuous rail marker whose top and height track the projection of the visible document window onto the on-page TOC entries. The yellow highlight now spans (and slides across) exactly the sections currently on screen, sized by how much of each section is visible, instead of snapping to one heading. Link labels keep a binary highlight (thresholded) while only the rail flows. Co-Authored-By: Claude Opus 4.8 --- src/scripts/site.js | 107 +++++++++++++++++++++++++++++++++++--------- src/styles/site.css | 16 ++++++- 2 files changed, 101 insertions(+), 22 deletions(-) diff --git a/src/scripts/site.js b/src/scripts/site.js index 52f91cb..1f8d31e 100644 --- a/src/scripts/site.js +++ b/src/scripts/site.js @@ -107,29 +107,94 @@ pre.appendChild(btn); }); - // --- right-rail TOC scrollspy --- + // --- right-rail TOC flow indicator --- + // A single rail marker whose top/height track the projection of the visible + // document window onto the TOC entries, so the highlight "flows" across the + // sections currently on screen instead of snapping to one heading. var tocLinks = document.querySelectorAll('.toc a[href^="#"]'); - if (tocLinks.length && 'IntersectionObserver' in window) { - var byId = {}; - tocLinks.forEach(function (a) { - byId[a.getAttribute('href').slice(1)] = a; + var docContent = document.querySelector('.doc-content'); + var tocList = document.querySelector('.toc ul'); + if (tocLinks.length && docContent && tocList) { + var sections = []; + tocLinks.forEach(function (link) { + var heading = document.getElementById(link.getAttribute('href').slice(1)); + if (heading) sections.push({ link: link, li: link.parentNode, heading: heading }); }); - var current = null; - var observer = new IntersectionObserver( - function (entries) { - entries.forEach(function (entry) { - if (!entry.isIntersecting) return; - var link = byId[entry.target.id]; - if (!link) return; - if (current) current.classList.remove('is-active'); - link.classList.add('is-active'); - current = link; + + if (sections.length) { + var marker = document.createElement('span'); + marker.className = 'toc-flow'; + tocList.appendChild(marker); + + var topbarH = + parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--topbar-h')) || 56; + + function updateFlow() { + var scrollY = window.pageYOffset; + var viewTop = scrollY + topbarH; // top reading line, below the sticky bar + var viewBottom = scrollY + window.innerHeight; + var readingH = Math.max(window.innerHeight - topbarH, 1); + var ulTop = tocList.getBoundingClientRect().top + scrollY; + var contentBottom = docContent.getBoundingClientRect().bottom + scrollY; + + var barTop = Infinity; + var barBottom = -Infinity; + + for (var i = 0; i < sections.length; i++) { + var s = sections[i]; + var secTop = s.heading.getBoundingClientRect().top + scrollY; + var secBottom = + i + 1 < sections.length + ? sections[i + 1].heading.getBoundingClientRect().top + scrollY + : contentBottom; + var secH = Math.max(secBottom - secTop, 1); + + var clipTop = Math.max(viewTop, secTop); + var clipBottom = Math.min(viewBottom, secBottom); + var visible = clipBottom - clipTop; + + if (visible <= 0) { + s.link.classList.remove('is-active'); + continue; + } + + // Project this section's visible slice onto its TOC entry's height. + var liRect = s.li.getBoundingClientRect(); + var eTop = liRect.top + scrollY; + var segTop = eTop + ((clipTop - secTop) / secH) * liRect.height; + var segBottom = eTop + ((clipBottom - secTop) / secH) * liRect.height; + if (segTop < barTop) barTop = segTop; + if (segBottom > barBottom) barBottom = segBottom; + + // Binary text highlight: lit while the section holds the top reading + // line or occupies a meaningful share of the viewport. + var containsTop = viewTop >= secTop && viewTop < secBottom; + var frac = visible / Math.min(readingH, secH); + s.link.classList.toggle('is-active', containsTop || frac >= 0.4); + } + + if (barBottom > barTop) { + marker.style.top = barTop - ulTop + 'px'; + marker.style.height = barBottom - barTop + 'px'; + marker.style.opacity = '1'; + } else { + marker.style.opacity = '0'; + } + } + + var ticking = false; + function onScroll() { + if (ticking) return; + ticking = true; + requestAnimationFrame(function () { + ticking = false; + updateFlow(); }); - }, - { rootMargin: '-56px 0px -70% 0px', threshold: 0 }, - ); - document.querySelectorAll('.doc-content h2[id], .doc-content h3[id]').forEach(function (h) { - observer.observe(h); - }); + } + + window.addEventListener('scroll', onScroll, { passive: true }); + window.addEventListener('resize', onScroll); + updateFlow(); + } } })(); diff --git a/src/styles/site.css b/src/styles/site.css index ddadce0..f4444b4 100644 --- a/src/styles/site.css +++ b/src/styles/site.css @@ -976,12 +976,27 @@ h3:hover .heading-anchor, } .toc ul { + position: relative; list-style: none; margin: 0; padding: 0; border-left: 1px solid var(--border); } +/* Rail marker that flows to span the sections currently on screen. */ +.toc-flow { + position: absolute; + left: -1px; + width: 2px; + top: 0; + height: 0; + background: var(--accent); + border-radius: 1px; + opacity: 0; + transition: opacity 0.2s ease; + pointer-events: none; +} + .toc li a { display: block; padding: 0.2rem 0 0.2rem 0.75rem; @@ -1001,7 +1016,6 @@ h3:hover .heading-anchor, .toc li a.is-active { color: var(--accent-strong); - border-left-color: var(--accent); } /* ---------- legal notice ---------- */