1import { Controller } from "@hotwired/stimulus"; 2 3export default class extends Controller { 4 static targets = ["container"]; 5 static values = { 6 selector: { type: String, default: "h2, h3" }, 7 excludeSelector: { type: String, default: ".border.mb-8.bg-white, .not-prose, pre, code" }, 8 offset: { type: Number, default: 100 }, 9 }; 10 11 connect() { 12 this.headings = []; 13 this.h2Headings = []; 14 this.headingStructure = []; 15 this.currentActiveId = null; 16 this.currentActiveH2 = null; 17 this.isScrolling = false; 18 this.scrollTimeout = null; 19 20 this.generateTableOfContents(); 21 this.bindScrollEvents(); 22 } 23 24 disconnect() { 25 if (this.scrollTimeout) { 26 clearTimeout(this.scrollTimeout); 27 } 28 window.removeEventListener("scroll", this.handleScroll); 29 } 30 31 generateTableOfContents() { 32 // Find all headings in the main content area 33 const contentArea = document.querySelector('[data-table-of-contents-target="content"]') || document; 34 const allHeadings = contentArea.querySelectorAll(this.selectorValue); 35 36 // Filter out headings that are inside excluded containers 37 const filteredHeadings = Array.from(allHeadings).filter((heading) => { 38 return !heading.closest(this.excludeSelectorValue); 39 }); 40 41 if (filteredHeadings.length === 0) { 42 this.containerTarget.innerHTML = 43 '<p class="text-sm text-neutral-400 dark:text-neutral-600">No headings found</p>'; 44 return; 45 } 46 47 // Store headings for scroll tracking 48 this.headings = filteredHeadings; 49 50 // Build hierarchical structure 51 this.buildHeadingStructure(filteredHeadings); 52 53 // Set initial active heading and render 54 this.updateActiveHeading(); 55 } 56 57 buildHeadingStructure(headings) { 58 this.headingStructure = []; 59 this.h2Headings = []; 60 let currentH2 = null; 61
62 headings.forEach((heading) => { 63 const id = heading.id || this.generateId(heading.textContent); 64 const text = heading.textContent.trim(); 65 const level = parseInt(heading.tagName.charAt(1)); 66 67 // Ensure the heading has an ID for linking 68 if (!heading.id) { 69 heading.id = id; 70 } 71 72 const headingData = { element: heading, id, text, level }; 73 74 if (level === 2) { 75 currentH2 = { ...headingData, children: [] }; 76 this.headingStructure.push(currentH2); 77 this.h2Headings.push(heading); 78 } else if (level === 3 && currentH2) { 79 currentH2.children.push(headingData); 80 } 81 }); 82 } 83 84 findActiveH2(activeHeading) { 85 if (!activeHeading) return null; 86 87 const activeLevel = parseInt(activeHeading.tagName.charAt(1)); 88 89 if (activeLevel === 2) { 90 return activeHeading; 91 } else if (activeLevel === 3) { 92 // Find the h2 that this h3 belongs to 93 for (const h2Section of this.headingStructure) { 94 if (h2Section.children.some((child) => child.element === activeHeading)) { 95 return h2Section.element; 96 } 97 } 98 } 99 100 return null; 101 } 102 103 renderTableOfContents(activeH2) { 104 if (this.headingStructure.length === 0) { 105 this.containerTarget.innerHTML = 106 '<p class="text-sm text-neutral-400 dark:text-neutral-600">No headings found</p>'; 107 return; 108 } 109 110 let html = 111 '<ul class="flex flex-col gap-2 border-l border-[color-mix(in_oklab,_var(--color-neutral-950),white_90%)] dark:border-[color-mix(in_oklab,_var(--color-neutral-950),white_20%)]">'; 112 113 // Render all H2 sections 114 this.headingStructure.forEach((section) => { 115 const isActiveSection = activeH2 && section.element === activeH2; 116 117 // Add the h2 heading 118 html += ` 119 <li class="-ml-px flex flex-col items-start gap-2"> 120 <a class="inline-block border-l border-transparent pl-5 sm:pl-4 text-base/8 text-neutral-400 dark:text-neutral-400 hover:border-neutral-950/25 hover:text-neutral-950 aria-[current]:border-neutral-950 aria-[current]:text-neutral-950 sm:text-sm/6 dark:hover:border-white/25 dark:hover:text-white dark:aria-[current]:border-white dark:aria-[current]:text-white font-medium" 121 href="#${section.id}" 122 data-toc-link="${section.id}"> 123 ${section.text} 124 </a> 125 </li> 126 `; 127 128 // Only show h3 children for the active section 129 if (isActiveSection && section.children.length > 0) { 130 section.children.forEach((child) => { 131 html += ` 132 <li class="-ml-px flex flex-col items-start gap-2"> 133 <a class="inline-block border-l border-transparent pl-8 sm:pl-7.5 text-base/8 text-neutral-400 dark:text-neutral-400 hover:border-neutral-950/25 hover:text-neutral-950 aria-[current]:border-neutral-950 aria-[current]:text-neutral-950 sm:text-sm/6 dark:hover:border-white/25 dark:hover:text-white dark:aria-[current]:border-white dark:aria-[current]:text-white" 134 href="#${child.id}" 135 data-toc-link="${child.id}"> 136 ${child.text} 137 </a> 138 </li> 139 `; 140 }); 141 } 142 }); 143 144 html += "</ul>"; 145 this.containerTarget.innerHTML = html; 146 147 // Add click event listeners to TOC links 148 this.addClickListeners(); 149 150 // Set active link 151 this.setActiveLink(this.currentActiveId); 152 } 153 154 generateId(text) { 155 return text 156 .toLowerCase() 157 .replace(/[^\w\s-]/g, "") // Remove special characters 158 .replace(/\s+/g, "-") // Replace spaces with hyphens 159 .replace(/-+/g, "-") // Replace multiple hyphens with single 160 .trim(); 161 } 162 163 setActiveLink(activeId) { 164 if (!activeId) return; 165 166 // Remove active state from all links 167 const allLinks = this.containerTarget.querySelectorAll("[data-toc-link]"); 168 allLinks.forEach((link) => { 169 link.removeAttribute("aria-current"); 170 }); 171 172 // Add active state to current link 173 const activeLink = this.containerTarget.querySelector(`[data-toc-link="${activeId}"]`); 174 if (activeLink) { 175 activeLink.setAttribute("aria-current", "location"); 176 } 177 } 178 179 addClickListeners() { 180 const links = this.containerTarget.querySelectorAll("[data-toc-link]"); 181 links.forEach((link) => { 182 link.addEventListener("click", (e) => { 183 e.preventDefault(); 184 const targetId = link.getAttribute("data-toc-link"); 185 const targetElement = document.getElementById(targetId); 186 187 if (targetElement) { 188 // Set scrolling flag to prevent updates during smooth scroll 189 this.isScrolling = true; 190 191 const offsetTop = targetElement.offsetTop - this.offsetValue; 192 window.scrollTo({ 193 top: offsetTop, 194 behavior: "smooth", 195 }); 196 197 // Update URL hash 198 history.pushState(null, null, `#${targetId}`); 199 200 // Set active immediately for better UX 201 this.setActiveLink(targetId); 202 203 // Clear scrolling flag after scroll completes 204 setTimeout(() => { 205 this.isScrolling = false;
206 }, 800); 207 } 208 }); 209 }); 210 } 211 212 bindScrollEvents() { 213 this.handleScroll = this.handleScroll.bind(this); 214 window.addEventListener("scroll", this.handleScroll, { passive: true }); 215 } 216 217 handleScroll() { 218 // Skip if we're programmatically scrolling 219 if (this.isScrolling) return; 220 221 // Skip if scroll is locked (body has position: fixed) 222 if (document.body.style.position === "fixed") return; 223 224 // Update immediately for snappy response 225 this.updateActiveHeading(); 226 227 // Also throttle for performance (but don't wait for scroll to stop) 228 if (this.scrollTimeout) { 229 clearTimeout(this.scrollTimeout); 230 } 231 232 this.scrollTimeout = setTimeout(() => { 233 // Final update to ensure accuracy 234 this.updateActiveHeading(); 235 }, 16); // ~60fps for smooth updates 236 } 237 238 updateActiveHeading() { 239 if (this.isScrolling) return; 240 241 // Skip if scroll is locked (body has position: fixed) 242 if (document.body.style.position === "fixed") return; 243 244 const scrollPosition = window.scrollY + this.offsetValue; 245 let activeHeading = null; 246 247 // Find the heading that's currently in view 248 // We want the last heading that's above or at the scroll position 249 for (let i = 0; i < this.headings.length; i++) { 250 const heading = this.headings[i]; 251 const headingTop = heading.offsetTop; 252 253 if (headingTop <= scrollPosition) { 254 activeHeading = heading; 255 } else { 256 break; 257 } 258 } 259 260 // If no heading is above the scroll position, use the first one 261 if (!activeHeading && this.headings.length > 0) { 262 activeHeading = this.headings[0]; 263 } 264 265 if (activeHeading) { 266 const newActiveId = activeHeading.id; 267 const newActiveH2 = this.findActiveH2(activeHeading); 268 269 // Update if the active heading changed 270 if (newActiveId !== this.currentActiveId) { 271 this.currentActiveId = newActiveId; 272 this.setActiveLink(newActiveId); 273 } 274 275 // Re-render TOC if the active H2 section changed 276 if (newActiveH2 !== this.currentActiveH2) { 277 this.currentActiveH2 = newActiveH2; 278 this.renderTableOfContents(newActiveH2); 279 } 280 } 281 } 282};
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.