PageSourceSearch

https://zer0-mistakes.com/assets/js/modules/navigation/scroll-spy.js

js zer0-mistakes.com collected 2026-09-28 07:09:04 UTC 15,210 bytes, 455 lines download raw bytes

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.