PageSourceSearch

https://dwightgingrich.com/js/toc.js

js dwightgingrich.com collected 2026-09-27 03:49:20 UTC 12,807 bytes, 319 lines download raw bytes

1(function () {
2    // Ensure global handlers only bind once
3    if (window.__tocGlobalInit) return;
4    window.__tocGlobalInit = true;
5
6    // Store cleanup function for previous TOC instance
7    let cleanupPreviousToc = null;
8
9    function initToc() {
10        // Cleanup previous instance if exists
11        if (cleanupPreviousToc) {
12            cleanupPreviousToc();
13            cleanupPreviousToc = null;
14        }
15
16        let tocExpanded = false;
17
18        // Get all heading elements in the article + any headings referenced by TOC
19        const headings = document.querySelectorAll('article h2[id], article h3[id], #post-faqs');
20        const desktopTocContainer = document.querySelector('#toc-container #toc');
21        const tocLinks = desktopTocContainer ? desktopTocContainer.querySelectorAll('a') : [];
22
23        if (!headings.length || !tocLinks.length) {
24            return;
25        }
26
27        // Create map for heading elements. First occurrence wins so duplicate
28        // ids (legacy builds without dedupe) resolve like native anchors.
29        const headingElements = {};
30        headings.forEach((heading) => {
31            const id = heading.getAttribute('id');
32            if (id && id !== 'toc-title' && !headingElements[id]) {
33                headingElements[id] = heading;
34            }
35        });
36
37        // Build a set of IDs that are actually in the TOC
38        const tocLinkIds = new Set();
39        tocLinks.forEach((link) => {
40            const href = link.getAttribute('href');
41            if (href && href.startsWith('#')) {
42                const targetId = href
43                    .substring(1)
44                    .replace(/[\n\r\t]/g, '')
45                    .trim();
46                tocLinkIds.add(targetId);
47                if (!headingElements[targetId]) {
48                    const element = document.getElementById(targetId);
49                    if (element) {
50                        headingElements[targetId] = element;
51                    }
52                }
53            }
54        });
55
56        // Find the TOC-visible ID for a heading (if h3 not in TOC, find parent h2)
57        function getTocVisibleId(id) {
58            if (tocLinkIds.has(id)) {
59                return id;
60            }
61            // Heading not in TOC, find parent h2
62            const element = headingElements[id];
63            if (!element) return null;
64
65            let sibling = element.previousElementSibling;
66            while (sibling) {
67                if (sibling.tagName === 'H2' && sibling.id && tocLinkIds.has(sibling.id)) {
68                    return sibling.id;
69                }
70                sibling = sibling.previousElementSibling;
71            }
72            return null;
73        }
74
75        function hasPassedMiddle(element) {
76            const rect = element.getBoundingClientRect();
77            const viewportMiddle = window.innerHeight / 2;
78            return rect.top <= viewportMiddle;
79        }
80
81        const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
82
83        // Track the last active ID to avoid redundant scroll operations
84        let lastActiveId = null;
85
86        // Find the TOC's scrollable container once (aside with overflow-auto or #toc-container)
87        const tocScrollParent = (function () {
88            let el = desktopTocContainer;
89            while (el && el !== document.body) {
90                el = el.parentElement;
91                if (!el) return null;
92                const overflow = getComputedStyle(el).overflowY;
93                if (overflow === 'auto' || overflow === 'scroll') return el;
94            }
95            return null;
96        })();
97
98        function setActiveLink(id) {
99            tocLinks.forEach((link) => link.classList.remove('active', 'parent-active'));
100
101            const sanitizedId = id.replace(/[\n\r\t]/g, '').trim();
102            let activeLink = null;
103
104            tocLinks.forEach((link) => {
105                const href = link.getAttribute('href');
106                if (href && href.startsWith('#')) {
107                    const linkId = href
108                        .substring(1)
109                        .replace(/[\n\r\t]/g, '')
110                        .trim();
111                    if (linkId === sanitizedId && !activeLink) {
112                        activeLink = link;
113                    }
114                }
115            });
116
117            if (activeLink) {
118                activeLink.classList.add('active');
119
120                // Auto-scroll TOC container to keep active item visible
121                if (lastActiveId !== sanitizedId && tocScrollParent) {
122                    lastActiveId = sanitizedId;
123                    const containerRect = tocScrollParent.getBoundingClientRect();
124                    const linkRect = activeLink.getBoundingClientRect();
125                    if (linkRect.top < containerRect.top || linkRect.bottom > containerRect.bottom) {
126                        const nextScrollTop =
127                            tocScrollParent.scrollTop + (linkRect.top - containerRect.top - containerRect.height / 3);
128                        tocScrollParent.scrollTo({
129                            top: nextScrollTop,
130                            behavior: prefersReducedMotion ? 'auto' : 'smooth',
131                        });
132                    }
133                }
134
135                // If this is an h3, also highlight its parent h2 link
136                const headingElement = document.getElementById(id);
137                if (headingElement && headingElement.tagName === 'H3') {
138                    let currentNode = headingElement;
139                    let foundH2 = null;
140
141                    while (currentNode && !foundH2) {
142                        let sibling = currentNode.previousElementSibling;
143                        while (sibling && !foundH2) {
144                            if (sibling.tagName === 'H2' && sibling.id) {
145                                foundH2 = sibling;
146                                break;
147                            }
148                            sibling = sibling.previousElementSibling;
149                        }
150                        if (!foundH2) {
151                            currentNode = currentNode.parentElement;
152                            if (!currentNode || currentNode.tagName === 'ARTICLE' || currentNode.tagName === 'BODY') {
153                                break;
154                            }
155                        }
156                    }
157
158                    if (foundH2 && foundH2.id) {
159                        const sanitizedParentId = foundH2.id.replace(/[\n\r\t]/g, '').trim();
160                        tocLinks.forEach((link) => {
161                            const href = link.getAttribute('href');
162                            if (href && href.startsWith('#')) {
163                                const linkId = href
164                                    .substring(1)
165                                    .replace(/[\n\r\t]/g, '')
166                                    .trim();
167                                if (linkId === sanitizedParentId && !link.classList.contains('active')) {
168                                    link.classList.add('parent-active');
169                                }
170                            }
171                        });
172                    }
173                }
174            }
175        }
176
177        // Set when a reader clicks a contents link, and honoured until the
178        // smooth scroll has finished. Without it the spy overrules the click:
179        // its rule is "the last heading above the viewport's middle", and the
180        // click leaves the target at y=100, so on a tall viewport any heading
181        // within the next few hundred pixels also qualifies and wins. Every
182        // section shorter than half a viewport highlighted the one after it.
183        let spySuppressedUntil = 0;
184
185        function updateActiveHeading() {
186            if (Date.now() < spySuppressedUntil) return;
187            let activeId = null;
188
189            // Find the last heading that has crossed the middle of the viewport
190            for (let i = headings.length - 1; i >= 0; i--) {
191                const id = headings[i].getAttribute('id');
192                if (id && headingElements[id] && hasPassedMiddle(headingElements[id])) {
193                    activeId = getTocVisibleId(id);
194                    if (activeId) break;
195                }
196            }
197
198            if (activeId) {
199                setActiveLink(activeId);
200            }
201        }
202
203        function handleTocLinkClick(e) {
204            const link = e.target.closest('a');
205            if (!link) return;
206
207            const href = link.getAttribute('href');
208            if (!href || !href.startsWith('#')) return;
209
210            // Let browser handle the Contents title link natively (#post-container
211            // now; #post kept for pages served from an older build)
212            if (href === '#post-container' || href === '#post') return;
213
214            const id = href
215                .substring(1)
216                .replace(/[\n\r\t]/g, '')
217                .trim();
218
219            e.preventDefault();
220
221            const element = headingElements[id] || document.getElementById(id);
222            if (!element) return;
223
224            if (window.history && window.history.pushState) {
225                window.history.pushState(null, null, href);
226            } else {
227                window.location.hash = id;
228            }
229
230            // The reader asked for this section, so it is the active one.
231            // Mark it before scrolling and hold the spy off until the scroll
232            // settles, or the spy immediately reassigns it.
233            const clickedId = getTocVisibleId(id) || id;
234            if (clickedId) {
235                setActiveLink(clickedId);
236            }
237            spySuppressedUntil = Date.now() + (prefersReducedMotion ? 200 : 1200);
238
239            const scrollOffset = 100; // Space above heading for navbar
240            const targetPosition = element.getBoundingClientRect().top + window.scrollY - scrollOffset;
241            window.scrollTo({ top: targetPosition, behavior: prefersReducedMotion ? 'auto' : 'smooth' });
242        }
243
244        // TOC toggle for mobile - entire header is clickable
245        function handleTocToggleClick(e) {
246            const tocHeader = e.target.closest('#mobile-toc-header');
247            if (!tocHeader) return;
248
249            e.preventDefault();
250            e.stopPropagation();
251
252            const mobileContainer = document.getElementById('toc-container-mobile');
253            const tocList = mobileContainer ? mobileContainer.querySelector('#mobile-toc-list') : null;
254
255            if (tocList) {
256                tocExpanded = !tocExpanded;
257
258                if (tocExpanded) {
259                    tocList.classList.remove('hidden');
260                    tocList.classList.add('block');
261                    tocList.classList.remove('xl:block');
262                } else {
263                    tocList.classList.add('hidden');
264                    tocList.classList.remove('block');
265                }
266
267                // Update aria-expanded for accessibility
268                tocHeader.setAttribute('aria-expanded', tocExpanded);
269
270                // Rotate the chevron icon
271                const tocToggle = tocHeader.querySelector('#mobile-toc-toggle');
272                if (tocToggle) {
273                    tocToggle.style.transform = tocExpanded ? 'rotate(180deg)' : 'rotate(0deg)';
274                }
275            }
276        }
277
278        // Scroll handler - no debounce for real-time updates
279        function handleScroll() {
280            updateActiveHeading();
281        }
282
283        // Resize handler
284        function handleResize() {
285            updateActiveHeading();
286        }
287
288        // Add event listeners
289        if (desktopTocContainer) {
290            desktopTocContainer.addEventListener('click', handleTocLinkClick);
291        }
292        document.addEventListener('click', handleTocToggleClick);
293        window.addEventListener('scroll', handleScroll, { passive: true });
294        window.addEventListener('resize', handleResize);
295
296        // Initial setup
297        setTimeout(updateActiveHeading, 100);
298
299        // Store cleanup function
300        cleanupPreviousToc = function () {
301            if (desktopTocContainer) {
302                desktopTocContainer.removeEventListener('click', handleTocLinkClick);
303            }
304            document.removeEventListener('click', handleTocToggleClick);
305            window.removeEventListener('scroll', handleScroll);
306            window.removeEventListener('resize', handleResize);
307        };
308    }
309
310    // Run on initial load
311    if (document.readyState === 'loading') {
312        document.addEventListener('DOMContentLoaded', initToc);
313    } else {
314        initToc();
315    }
316
317    // Re-run on Turbo navigation
318    document.addEventListener('turbo:load', initToc);
319})();

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.