also looking at this
fix(toc): keep active outline entry visible
#83
3 files
+68
-6
| @@ -116,7 +116,7 @@ | ||
| 116 | 116 | function initPageTOC() { |
| 117 | 117 | const tocContainer = document.querySelector('.page-toc'); |
| 118 | 118 | 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'); | |
| 120 | 120 | const headings = Array.from(document.querySelectorAll('h2[id], h3[id], h4[id], h5[id], h6[id]')); |
| 121 | 121 | const scrollContainer = document.querySelector('.BPContent'); |
| 122 | 122 | |
| @@ -144,18 +144,42 @@ function initPageTOC() { | ||
| 144 | 144 | }; |
| 145 | 145 | } |
| 146 | 146 | |
| 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. | |
| 148 | 168 | function activateLink(hash) { |
| 149 | 169 | // Remove previous active class |
| 150 | 170 | tocLinks.forEach(link => link.classList.remove('active')); |
| 151 | 171 | |
| 152 | 172 | 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 + '"]'); | |
| 155 | 176 | |
| 156 | if (activeLink) { | |
| 157 | activeLink.classList.add('active'); | |
| 177 | activeLinks.forEach(link => { | |
| 178 | link.classList.add('active'); | |
| 179 | keepActiveLinkVisible(link); | |
| 180 | }); | |
| 158 | 181 | |
| 182 | if (activeLink) { | |
| 159 | 183 | // Position the marker at the active link |
| 160 | 184 | const linkRect = activeLink.getBoundingClientRect(); |
| 161 | 185 | const containerRect = tocContainer.getBoundingClientRect(); |
| @@ -329,6 +353,16 @@ function initPageTOC() { | ||
| 329 | 353 | window.__bpTocHashChange = settleActiveLink; |
| 330 | 354 | window.addEventListener('hashchange', settleActiveLink); |
| 331 | 355 | |
| 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 | ||
| 332 | 366 | // Initial update |
| 333 | 367 | requestAnimationFrame(setActiveLink); |
| 334 | 368 | } |