ReviewOS

also looking at this

stacks/bunpress

fix(toc): keep active outline entry visible

#83
Merged chrisbbreuer wants to merge codex/fix-scrollspy-outline into main
3 files +68 -6
packages/bunpress/src/templates/page-toc.stxmodified+40-6
Changes to packages/bunpress/src/templates/page-toc.stx
@@ -116,7 +116,7 @@
116116function initPageTOC() {
117117 const tocContainer = document.querySelector('.page-toc');
118118 const marker = document.querySelector('.outline-marker');
119 const tocLinks = document.querySelectorAll('.page-toc a');
119 const tocLinks = document.querySelectorAll('.page-toc a, .bp-local-outline-items a');
120120 const headings = Array.from(document.querySelectorAll('h2[id], h3[id], h4[id], h5[id], h6[id]'));
121121 const scrollContainer = document.querySelector('.BPContent');
122122
@@ -144,18 +144,42 @@ function initPageTOC() {
144144 };
145145 }
146146
147 // Activate a TOC link by hash
147 // Keep the active entry inside whichever outline is currently visible. On
148 // long pages the marker used to continue moving below the fixed aside while
149 // the aside itself remained at scrollTop 0, making a working scrollspy look
150 // completely dead once the reader passed the first screen of headings.
151 function keepActiveLinkVisible(activeLink) {
152 const outlineViewport = activeLink.closest('.BPDocAside') || activeLink.closest('.bp-local-outline-items');
153 if (!outlineViewport || outlineViewport.clientHeight === 0) return;
154
155 const linkRect = activeLink.getBoundingClientRect();
156 const viewportRect = outlineViewport.getBoundingClientRect();
157 const inset = 24;
158
159 if (linkRect.top < viewportRect.top + inset) {
160 outlineViewport.scrollTop += linkRect.top - viewportRect.top - inset;
161 } else if (linkRect.bottom > viewportRect.bottom - inset) {
162 outlineViewport.scrollTop += linkRect.bottom - viewportRect.bottom + inset;
163 }
164 }
165
166 // Activate matching links in both the fixed desktop outline and the
167 // collapsible in-content outline used at narrower widths.
148168 function activateLink(hash) {
149169 // Remove previous active class
150170 tocLinks.forEach(link => link.classList.remove('active'));
151171
152172 if (hash) {
153 // Find and activate new link
154 const activeLink = tocContainer.querySelector('a[href="' + decodeURIComponent(hash) + '"]');
173 const decodedHash = decodeURIComponent(hash);
174 const activeLinks = Array.from(tocLinks).filter(link => link.getAttribute('href') === decodedHash);
175 const activeLink = tocContainer.querySelector('a[href="' + decodedHash + '"]');
155176
156 if (activeLink) {
157 activeLink.classList.add('active');
177 activeLinks.forEach(link => {
178 link.classList.add('active');
179 keepActiveLinkVisible(link);
180 });
158181
182 if (activeLink) {
159183 // Position the marker at the active link
160184 const linkRect = activeLink.getBoundingClientRect();
161185 const containerRect = tocContainer.getBoundingClientRect();
@@ -329,6 +353,16 @@ function initPageTOC() {
329353 window.__bpTocHashChange = settleActiveLink;
330354 window.addEventListener('hashchange', settleActiveLink);
331355
356 // Opening the responsive outline does not move the page and therefore does
357 // not emit a scroll event. Re-run once it becomes visible so its active
358 // entry and internal scroll position immediately match the current section.
359 const inlineOutline = document.querySelector('.bp-local-outline');
360 if (inlineOutline) {
361 inlineOutline.addEventListener('toggle', () => {
362 if (inlineOutline.open) requestAnimationFrame(setActiveLink);
363 });
364 }
365
332366 // Initial update
333367 requestAnimationFrame(setActiveLink);
334368}