1// Feature: ZER0-008 2/** 3 * =================================================================== 4 * SCROLL SPY - Reading-position TOC highlighting 5 * =================================================================== 6 * 7 * File: scroll-spy.js 8 * Path: assets/js/modules/navigation/scroll-spy.js 9 * Purpose: Track the section being read and bold its TOC link 10 * 11 * How the active heading is chosen: 12 * The active heading is the LAST heading whose top has crossed the 13 * "reading line" - a horizontal line `offset` pixels below the top of the 14 * viewport, matching the `scroll-padding-top` that anchor navigation uses. 15 * Once the page is scrolled to the bottom, the last heading wins (trailing 16 * sections can be shorter than the viewport and would never reach the 17 * line). The answer is recomputed from scratch on every scroll frame, so 18 * the highlight cannot drift out of sync with the page. 19 * 20 * The previous implementation asked IntersectionObserver for the "most 21 * visible" heading. Headings are only a few pixels tall, so every heading 22 * inside the observer band reported the same intersectionRatio and the 23 * winner was whichever entry happened to be in that callback's batch - 24 * headings leaving the band never triggered a re-evaluation at all. That 25 * is what made the bolding jump around and stick on the wrong entry. 26 * 27 * Features: 28 * - Deterministic, position-based active section (no ratio guessing) 29 * - rAF-throttled passive scroll listener; heading offsets are cached and 30 * re-measured on resize / content reflow 31 * - Holds the clicked link active while a smooth scroll animates, so 32 * intermediate headings don't flash 33 * - Keeps the active link visible inside the TOC without ever scrolling 34 * the page itself 35 * 36 * Usage: 37 * import { ScrollSpy } from './scroll-spy.js'; 38 * const scrollSpy = new ScrollSpy(); 39 * 40 * =================================================================== 41 */ 42 43import { config } from './config.js'; 44 45const ACTIVE_CLASS = 'active'; 46 47/** How long (ms) to keep a clicked TOC link active while the page animates. */ 48const CLICK_GUARD_MS = 1200; 49 50/** 51 * Get element safely with error handling 52 * @param {string} selector - CSS selector 53 * @returns {Element|null} 54 */ 55function getElement(selector) { 56 try { 57 return document.querySelector(selector); 58 } catch (error) { 59 console.warn(`ScrollSpy: Element not found - ${selector}`); 60 return null; 61 } 62} 63 64/** 65 * Get all elements safely with error handling 66 * @param {string} selector - CSS selector 67 * @returns {NodeList} 68 */ 69function getElements(selector) { 70 try { 71 return document.querySelectorAll(selector); 72 } catch (error) { 73 console.warn(`ScrollSpy: Elements not found - ${selector}`); 74 return []; 75 } 76} 77 78/** 79 * Current vertical scroll position, normalized across browsers. 80 * @returns {number} 81 */ 82function scrollTop() { 83 return window.scrollY !== undefined ? window.scrollY : window.pageYOffset; 84} 85 86export class ScrollSpy { 87 constructor() { 88 this.tocLinks = getElements(config.selectors.tocLinks); 89 this.headings = this._getHeadings(); 90 this.currentActive = null; 91 92 // Cached measurements - invalidated (not recomputed) by resize and 93 // content reflow, then refreshed inside the next animation frame. 94 this._offsetPx = 0; 95 this._needsMeasure = true; 96 this._frame = null; 97 98 // Click guard: id of the heading a TOC click is scrolling towards. 99 this._pendingId = null; 100 this._pendingExpires = 0; 101 102 this._resizeObserver = null; 103 104 if (this.headings.length === 0 || this.tocLinks.length === 0) { 105 console.log('ScrollSpy: No TOC or headings found, skipping initialization'); 106 return; 107 } 108 109 this._onScroll = () => this._requestUpdate(); 110 this._onReflow = () => this._invalidate(); 111 this._onNavigationScroll = event => this._handleNavigationScroll(event); 112 this._onUserScroll = () => this._cancelClickGuard(); 113 114 this._init(); 115 } 116 117 /** 118 * Get all headings that have corresponding TOC links, in document order. 119 * Duplicate links to the same heading keep the first link. 120 * @private 121 * @returns {Array<{element: Element, link: Element, id: string, top: number}>} 122 */ 123 _getHeadings() { 124 const headings = []; 125 const seen = new Set(); 126 127 this.tocLinks.forEach(link => { 128 const href = link.getAttribute('href'); 129 if (!href || !href.startsWith('#') || href.length < 2) return; 130 131 const id = decodeURIComponent(href.substring(1)); 132 if (seen.has(id)) return; 133 134 const heading = document.getElementById(id); 135 if (!heading) return; 136 137 seen.add(id); 138 headings.push({ element: heading, link: link, id: id, top: 0 }); 139 }); 140 141 return headings; 142 } 143 144 /** 145 * Attach listeners and paint the initial active link. 146 * @private 147 */ 148 _init() { 149 window.addEventListener('scroll', this._onScroll, { passive: true }); 150 window.addEventListener('resize', this._onReflow); 151 window.addEventListener('load', this._onReflow); 152 window.addEventListener('hashchange', this._onScroll); 153 154 // A TOC click starts an animated scroll; hold that link active until 155 // the page lands (see _handleNavigationScroll)... 156 document.addEventListener('navigation:scroll', this._onNavigationScroll); 157 158 // ...but a reader who grabs the wheel mid-animation has taken over â 159 // drop the hold and follow them immediately. 160 window.addEventListener('wheel', this._onUserScroll, { passive: true }); 161 window.addEventListener('touchstart', this._onUserScroll, { passive: true }); 162 163 // Images, embeds, mermaid diagrams and collapsibles change heading 164 // offsets after load - re-measure when the content box resizes. 165 if (typeof ResizeObserver !== 'undefined') { 166 const content = getElement(config.selectors.mainContent) || document.body; 167 this._resizeObserver = new ResizeObserver(this._onReflow); 168 this._resizeObserver.observe(content); 169 } 170 171 this._update(); 172 173 console.log(`ScrollSpy: Tracking ${this.headings.length} headings`); 174 } 175 176 /**
177 * Mark cached offsets stale and schedule a recompute. 178 * @private 179 */ 180 _invalidate() { 181 this._needsMeasure = true; 182 this._requestUpdate(); 183 } 184 185 /** 186 * Coalesce updates into one per animation frame. 187 * @private 188 */ 189 _requestUpdate() { 190 if (this._frame !== null) return; 191 this._frame = requestAnimationFrame(() => this._update()); 192 } 193 194 /** 195 * Distance (px) from the top of the viewport to the reading line. 196 * Derived from the document's `scroll-padding-top` so the highlight lines 197 * up with where anchor navigation parks a heading; `config.scrollSpy.offset` 198 * pins it explicitly when a fork needs to. 199 * @private 200 * @returns {number} 201 */ 202 _readingOffset() { 203 const configured = config.scrollSpy.offset; 204 if (typeof configured === 'number' && Number.isFinite(configured)) { 205 return configured; 206 } 207 208 const padding = parseFloat( 209 getComputedStyle(document.documentElement).scrollPaddingTop 210 ); 211 if (Number.isFinite(padding)) return padding; 212 213 return config.smoothScroll.offset; 214 } 215 216 /** 217 * Cache each heading's document offset and sort into document order. 218 * @private 219 */ 220 _measure() { 221 const offset = scrollTop(); 222
223 this.headings.forEach(heading => { 224 heading.top = heading.element.getBoundingClientRect().top + offset; 225 }); 226 this.headings.sort((a, b) => a.top - b.top); 227 228 this._offsetPx = this._readingOffset(); 229 this._needsMeasure = false; 230 } 231 232 /** 233 * True when the page cannot scroll any further down. 234 * @private 235 * @returns {boolean} 236 */ 237 _atBottom() { 238 const doc = document.documentElement; 239 const tolerance = config.scrollSpy.tolerance; 240 return window.innerHeight + scrollTop() >= doc.scrollHeight - tolerance; 241 } 242 243 /** 244 * Resolve which heading the reader is currently in. 245 * @private 246 * @returns {{element: Element, link: Element, id: string, top: number}|null} 247 */ 248 _resolveActive() { 249 if (this.headings.length === 0) return null; 250 251 // Trailing sections shorter than the viewport can never reach the 252 // reading line, so the bottom of the page belongs to the last heading. 253 if (this._atBottom()) return this.headings[this.headings.length - 1]; 254 255 const line = scrollTop() + this._offsetPx + config.scrollSpy.tolerance; 256 257 let active = null; 258 for (let i = 0; i < this.headings.length; i++) { 259 if (this.headings[i].top > line) break; 260 active = this.headings[i]; 261 } 262 263 // Above the first heading, the first section is the one being read. 264 return active || this.headings[0]; 265 } 266 267 /** 268 * Recompute and apply the active link. Runs at most once per frame. 269 * @private 270 */ 271 _update() { 272 this._frame = null; 273 274 if (this._needsMeasure) this._measure(); 275 276 const active = this._resolveActive(); 277 if (!active) return; 278 279 if (this._pendingId !== null) { 280 const arrived = active.id === this._pendingId; 281 const expired = performance.now() > this._pendingExpires; 282 if (!arrived && !expired) return; 283 this._pendingId = null; 284 } 285 286 this._setActiveLink(active.link); 287 } 288 289 /** 290 * Light the clicked link immediately and hold it until the animated 291 * scroll lands on it, so headings passed on the way don't flash. 292 * @private 293 * @param {CustomEvent} event 294 */ 295 _handleNavigationScroll(event) { 296 const targetId = event && event.detail ? event.detail.targetId : null; 297 if (!targetId) return; 298 299 const heading = this.headings.find(h => h.id === targetId); 300 if (!heading) return; 301 302 this._pendingId = targetId; 303 this._pendingExpires = performance.now() + CLICK_GUARD_MS; 304 this._setActiveLink(heading.link); 305 } 306 307 /** 308 * Release the click guard so the positional rule takes over again. 309 * @private 310 */ 311 _cancelClickGuard() { 312 if (this._pendingId === null) return; 313 this._pendingId = null; 314 this._requestUpdate(); 315 } 316 317 /** 318 * Set active link with visual feedback 319 * @private 320 * @param {Element} link 321 */ 322 _setActiveLink(link) { 323 if (!link) return; 324 // Re-apply if something else stripped the class, so the highlight 325 // heals itself rather than silently disappearing. 326 if (this.currentActive === link && link.classList.contains(ACTIVE_CLASS)) return; 327 328 // Remove previous active state 329 this.tocLinks.forEach(l => { 330 if (l === link) return; 331 l.classList.remove(ACTIVE_CLASS); 332 l.removeAttribute('aria-current'); 333 }); 334 335 // Add active state 336 link.classList.add(ACTIVE_CLASS); 337 link.setAttribute('aria-current', 'true'); 338 this.currentActive = link; 339 340 // Scroll TOC to show active link (if needed) 341 this._scrollTocToActiveLink(link); 342 343 // Dispatch custom event for other modules 344 document.dispatchEvent(new CustomEvent('navigation:sectionChange', { 345 detail: { 346 link: link, 347 href: link.getAttribute('href') 348 } 349 })); 350 } 351 352 /** 353 * Nearest scrollable ancestor of the TOC link, if any. 354 * `.bd-toc` scrolls on desktop and `.offcanvas-body` on mobile, so the 355 * container is resolved at call time rather than assumed. 356 * @private 357 * @param {Element} link 358 * @returns {Element|null} 359 */ 360 _getTocScrollContainer(link) { 361 const hinted = getElement(config.selectors.tocContainer); 362 const scrolls = el => 363 el.scrollHeight > el.clientHeight + 1 && 364 /(auto|scroll)/.test(getComputedStyle(el).overflowY); 365 366 if (hinted && hinted.contains(link) && scrolls(hinted)) return hinted; 367 368 let node = link.parentElement; 369 while (node && node !== document.body) { 370 if (scrolls(node)) return node; 371 node = node.parentElement; 372 } 373 return null; 374 } 375 376 /** 377 * Keep the active link visible inside the TOC's own scroll container. 378 * Adjusts `scrollTop` directly - `scrollIntoView()` would bubble up and 379 * scroll the page, which feeds straight back into the spy. 380 * @private 381 * @param {Element} link 382 */ 383 _scrollTocToActiveLink(link) { 384 const container = this._getTocScrollContainer(link); 385 if (!container) return; 386 387 const linkRect = link.getBoundingClientRect(); 388 const containerRect = container.getBoundingClientRect(); 389 const margin = 8; 390 391 if (linkRect.top < containerRect.top + margin) { 392 container.scrollTop -= (containerRect.top + margin) - linkRect.top; 393 } else if (linkRect.bottom > containerRect.bottom - margin) { 394 container.scrollTop += linkRect.bottom - (containerRect.bottom - margin); 395 } 396 } 397 398 /** 399 * Manually set active section by ID 400 * @param {string} id - Heading ID to activate 401 */ 402 setActiveById(id) { 403 const heading = this.headings.find(h => h.id === id); 404 if (heading) { 405 this._setActiveLink(heading.link); 406 } 407 } 408 409 /** 410 * Get current active heading 411 * @returns {{element: Element, link: Element, id: string}|null} 412 */ 413 getActive() { 414 if (!this.currentActive) return null; 415 return this.headings.find(h => h.link === this.currentActive) || null; 416 } 417 418 /** 419 * Cleanup listeners and observers 420 */ 421 destroy() { 422 if (this._onScroll) { 423 window.removeEventListener('scroll', this._onScroll); 424 window.removeEventListener('hashchange', this._onScroll); 425 } 426 if (this._onReflow) { 427 window.removeEventListener('resize', this._onReflow); 428 window.removeEventListener('load', this._onReflow); 429 } 430 if (this._onNavigationScroll) { 431 document.removeEventListener('navigation:scroll', this._onNavigationScroll); 432 } 433 if (this._onUserScroll) { 434 window.removeEventListener('wheel', this._onUserScroll); 435 window.removeEventListener('touchstart', this._onUserScroll); 436 } 437 if (this._resizeObserver) { 438 this._resizeObserver.disconnect(); 439 this._resizeObserver = null; 440 } 441 if (this._frame !== null) { 442 cancelAnimationFrame(this._frame); 443 this._frame = null; 444 } 445 446 this.tocLinks.forEach(l => { 447 l.classList.remove(ACTIVE_CLASS); 448 l.removeAttribute('aria-current'); 449 }); 450 this.currentActive = null; 451 console.log('ScrollSpy: Destroyed'); 452 } 453} 454 455export default ScrollSpy;
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.