PageSourceSearch

https://railsblocks.com/assets/controllers/table_of_contents_contr…189890b9cfab901866edc5ad7ebf80b6e717537a.js

js railsblocks.com collected 2026-09-25 20:24:22 UTC 9,110 bytes, 282 lines download raw bytes

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.