also looking at this
fix(toc): keep active outline entry visible
#83
3 files
+68
-6
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -117,6 +117,12 @@ | ||
| 117 | 117 | background-color: var(--bp-c-default-soft, var(--bp-c-gray-soft)); |
| 118 | 118 | } |
| 119 | 119 | |
| 120 | .bp-local-outline-items a.active { | |
| 121 | color: var(--bp-c-brand-1); | |
| 122 | background-color: var(--bp-c-brand-soft); | |
| 123 | border-left-color: var(--bp-c-brand-1); | |
| 124 | } | |
| 125 | ||
| 120 | 126 | .bp-local-outline-items a:focus-visible { |
| 121 | 127 | outline: 2px solid var(--bp-c-brand-1); |
| 122 | 128 | outline-offset: -2px; |
| @@ -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 | } |
| @@ -0,0 +1,22 @@ | ||
| 1 | import { describe, expect, test } from 'bun:test' | |
| 2 | import { readFileSync } from 'node:fs' | |
| 3 | import { join } from 'node:path' | |
| 4 | ||
| 5 | const root = join(import.meta.dir, '..') | |
| 6 | const pageToc = readFileSync(join(root, 'packages/bunpress/src/templates/page-toc.stx'), 'utf8') | |
| 7 | const pageOutline = readFileSync(join(root, 'packages/bunpress/src/templates/page-outline.stx'), 'utf8') | |
| 8 | ||
| 9 | describe('page outline scrollspy', () => { | |
| 10 | test('tracks both responsive outline variants', () => { | |
| 11 | expect(pageToc).toContain("document.querySelectorAll('.page-toc a, .bp-local-outline-items a')") | |
| 12 | expect(pageOutline).toContain('.bp-local-outline-items a.active') | |
| 13 | }) | |
| 14 | ||
| 15 | test('keeps the active link inside the visible outline viewport', () => { | |
| 16 | expect(pageToc).toContain('function keepActiveLinkVisible(activeLink)') | |
| 17 | expect(pageToc).toContain("activeLink.closest('.BPDocAside') || activeLink.closest('.bp-local-outline-items')") | |
| 18 | expect(pageToc).toContain('outlineViewport.scrollTop += linkRect.top - viewportRect.top - inset') | |
| 19 | expect(pageToc).toContain('outlineViewport.scrollTop += linkRect.bottom - viewportRect.bottom + inset') | |
| 20 | expect(pageToc).toContain('keepActiveLinkVisible(link)') | |
| 21 | }) | |
| 22 | }) | |