@@ -29,85 +29,111 @@ const filteredHeadings = headings.filter(h => h.depth <= 3);
2929</nav >
3030
3131<script >
32- function initTOC() {
33- const tocLinks = document.querySelectorAll('.toc-link');
34- const indicator = document.querySelector('.toc-indicator') as HTMLElement;
32+ (() => {
33+ let cleanupToc: (() => void) | undefined;
3534
36- if (!tocLinks.length || !indicator) return;
35+ function initTOC() {
36+ if (cleanupToc) cleanupToc();
3737
38- const headingElements = Array.from(tocLinks).map(link => {
39- const slug = link.getAttribute('data-heading-slug');
40- return document.getElementById(slug || '');
41- }).filter(Boolean) as HTMLElement[];
38+ const tocRoot = document.querySelector('.toc');
39+ if (!tocRoot) return;
4240
43- let activeLink: Element | null = null;
41+ const tocList = tocRoot.querySelector('.toc-list');
42+ const indicator = tocRoot.querySelector('.toc-indicator') as HTMLElement | null;
43+ const tocLinks = Array.from(tocRoot.querySelectorAll<HTMLAnchorElement>('.toc-link'));
4444
45- function updateActiveHeading() {
46- const scrollY = window.scrollY;
47- const offset = 120;
45+ if (!tocList || !indicator || tocLinks.length === 0) return;
4846
49- let currentHeading: HTMLElement | null = null;
47+ const linksBySlug = new Map<string, HTMLAnchorElement>();
48+ for (const link of tocLinks) {
49+ const slug = link.dataset.headingSlug;
50+ if (slug) linksBySlug.set(slug, link);
51+ }
5052
51- for (const heading of headingElements) {
52- if (heading.offsetTop - offset <= scrollY) {
53- currentHeading = heading;
54- }
53+ const headingElements = Array.from(linksBySlug.keys())
54+ .map((slug) => document.getElementById(slug))
55+ .filter(Boolean) as HTMLElement[];
56+ if (headingElements.length === 0) return;
57+
58+ let activeLink: HTMLAnchorElement | null = null;
59+ let scrollRaf: number | null = null;
60+
61+ function setActiveLink(nextLink: HTMLAnchorElement | null) {
62+ if (!nextLink || nextLink === activeLink) return;
63+ if (activeLink) activeLink.classList.remove('active');
64+ nextLink.classList.add('active');
65+ activeLink = nextLink;
66+
67+ const linkRect = nextLink.getBoundingClientRect();
68+ const listRect = tocList.getBoundingClientRect();
69+ const top = linkRect.top - listRect.top;
70+ indicator.style.transform = `translateY(${top}px)`;
71+ indicator.style.height = `${linkRect.height}px`;
72+ indicator.style.opacity = '1';
5573 }
5674
57- if (currentHeading) {
58- const slug = currentHeading.id;
59- const newActiveLink = document.querySelector(`.toc-link[data-heading-slug="${slug}"]`);
60-
61- if (newActiveLink && newActiveLink !== activeLink) {
62- // Remove active class from previous
63- if (activeLink) {
64- activeLink.classList.remove('active');
65- }
66-
67- // Add active class to new
68- newActiveLink.classList.add('active');
69- activeLink = newActiveLink;
70-
71- // Update indicator position
72- const linkRect = newActiveLink.getBoundingClientRect();
73- const tocRect = document.querySelector('.toc-list')?.getBoundingClientRect();
74-
75- if (tocRect) {
76- const top = linkRect.top - tocRect.top;
77- indicator.style.transform = `translateY(${top}px)`;
78- indicator.style.height = `${linkRect.height}px`;
79- indicator.style.opacity = '1';
80- }
75+ function updateActiveHeading() {
76+ const scrollY = window.scrollY;
77+ const offset = 120;
78+ let currentHeading: HTMLElement | null = null;
79+
80+ for (const heading of headingElements) {
81+ if (heading.offsetTop - offset <= scrollY) currentHeading = heading;
8182 }
83+ if (!currentHeading) return;
84+
85+ const nextLink = linksBySlug.get(currentHeading.id) || null;
86+ setActiveLink(nextLink);
8287 }
83- }
8488
85- // Smooth scroll on click
86- tocLinks.forEach(link => {
87- link.addEventListener('click', (e) => {
88- e.preventDefault();
89- const slug = link.getAttribute('data-heading-slug');
90- const target = document.getElementById(slug || '');
91- if (target) {
92- const offset = 100;
93- const targetPosition = target.offsetTop - offset;
94- window.scrollTo({
95- top: targetPosition,
96- behavior: 'smooth'
97- });
98- }
99- });
100- });
89+ function onScroll() {
90+ if (scrollRaf !== null) return;
91+ scrollRaf = window.requestAnimationFrame(() => {
92+ updateActiveHeading();
93+ scrollRaf = null;
94+ });
95+ }
10196
102- // Initial check and scroll listener
103- updateActiveHeading();
104- window.addEventListener('scroll', updateActiveHeading, { passive: true });
105- }
97+ function onTocClick(event: Event) {
98+ const target = event.target as HTMLElement | null;
99+ const link = target?.closest<HTMLAnchorElement>('.toc-link');
100+ if (!link) return;
101+ event.preventDefault();
106102
107- // Initialize on page load
108- document.addEventListener('DOMContentLoaded', initTOC);
109- // Re-initialize on Astro page transitions
110- document.addEventListener('astro:page-load', initTOC);
103+ const slug = link.dataset.headingSlug;
104+ const heading = slug ? document.getElementById(slug) : null;
105+ if (!heading) return;
106+
107+ window.scrollTo({
108+ top: heading.offsetTop - 100,
109+ behavior: 'smooth',
110+ });
111+ }
112+
113+ tocRoot.addEventListener('click', onTocClick);
114+ window.addEventListener('scroll', onScroll, { passive: true });
115+ updateActiveHeading();
116+
117+ cleanupToc = () => {
118+ tocRoot.removeEventListener('click', onTocClick);
119+ window.removeEventListener('scroll', onScroll);
120+ if (scrollRaf !== null) {
121+ window.cancelAnimationFrame(scrollRaf);
122+ scrollRaf = null;
123+ }
124+ };
125+ }
126+
127+ if (document.readyState === 'loading') {
128+ document.addEventListener('DOMContentLoaded', initTOC, { once: true });
129+ } else {
130+ initTOC();
131+ }
132+ document.addEventListener('astro:page-load', initTOC);
133+ document.addEventListener('astro:before-swap', () => {
134+ if (cleanupToc) cleanupToc();
135+ });
136+ })();
111137</script >
112138
113139<style >
0 commit comments