| 1 | <script lang="ts"> |
| 2 | import { page } from '$app/stores'; |
| 3 | import { useIntersectionObserver } from 'runed'; |
| 4 | |
| 5 | interface TocItem { |
| 6 | id: string; |
| 7 | text: string; |
| 8 | level: number; |
| 9 | } |
| 10 | |
| 11 | let items = $state<TocItem[]>([]); |
| 12 | let activeId = $state(''); |
| 13 | let headingElements = $state<HTMLElement[]>([]); |
| 14 | |
| 15 | // Re-extract headings when the page changes |
| 16 | $effect(() => { |
| 17 | $page.url.pathname; |
| 18 | |
| 19 | requestAnimationFrame(() => { |
| 20 | const article = document.querySelector('article'); |
| 21 | if (!article) return; |
| 22 | |
| 23 | const headings = article.querySelectorAll<HTMLElement>('h2, h3'); |
| 24 | const newItems: TocItem[] = []; |
| 25 | const newElements: HTMLElement[] = []; |
| 26 | |
| 27 | headings.forEach((heading) => { |
| 28 | if (!heading.id) { |
| 29 | heading.id = |
| 30 | heading.textContent |
| 31 | ?.toLowerCase() |
| 32 | .replace(/[^a-z0-9]+/g, '-') |
| 33 | .replace(/(^-|-$)/g, '') ?? ''; |
| 34 | } |
| 35 | newItems.push({ |
| 36 | id: heading.id, |
| 37 | text: heading.textContent ?? '', |
| 38 | level: parseInt(heading.tagName[1]) |
| 39 | }); |
| 40 | newElements.push(heading); |
| 41 | }); |
| 42 | |
| 43 | items = newItems; |
| 44 | headingElements = newElements; |
| 45 | }); |
| 46 | }); |
| 47 | |
| 48 | // Track active heading via runed's useIntersectionObserver |
| 49 | useIntersectionObserver( |
| 50 | () => headingElements, |
| 51 | (entries) => { |
| 52 | for (const entry of entries) { |
| 53 | if (entry.isIntersecting) { |
| 54 | activeId = (entry.target as HTMLElement).id; |
| 55 | break; |
| 56 | } |
| 57 | } |
| 58 | }, |
| 59 | { rootMargin: '-80px 0px -60% 0px', threshold: 0 } |
| 60 | ); |
| 61 | </script> |
| 62 | |
| 63 | {#if items.length > 0} |
| 64 | <nav class="space-y-1 text-sm" aria-label="Table of contents"> |
| 65 | <h4 |
| 66 | class="font-display mb-3 text-xs font-semibold tracking-wider text-gray-500 uppercase dark:text-gray-400" |
| 67 | > |
| 68 | On this page |
| 69 | </h4> |
| 70 | {#each items as item} |
| 71 | <a |
| 72 | href="#{item.id}" |
| 73 | class="block border-l-2 py-1 transition-colors {item.level === 3 |
| 74 | ? 'pl-5' |
| 75 | : 'pl-3'} {activeId === item.id |
| 76 | ? 'border-primary text-primary font-medium dark:border-primary-light dark:text-primary-light' |
| 77 | : 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-gray-600 dark:hover:text-gray-300'}" |
| 78 | > |
| 79 | {item.text} |
| 80 | </a> |
| 81 | {/each} |
| 82 | </nav> |
| 83 | {/if} |