main
svelte 83 lines 2.08 KB
Raw
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}