You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
148 lines
3.2 KiB
148 lines
3.2 KiB
<script lang="ts">
|
|
import { onMount, tick } from 'svelte';
|
|
import { page } from '$app/state';
|
|
|
|
interface Heading {
|
|
readonly id: string;
|
|
readonly text: string;
|
|
readonly level: 2 | 3;
|
|
}
|
|
|
|
let headings = $state<Heading[]>([]);
|
|
let activeId = $state<string | null>(null);
|
|
|
|
let observer: IntersectionObserver | undefined;
|
|
|
|
async function collectHeadings() {
|
|
await tick();
|
|
const article = document.querySelector('main .article');
|
|
if (!article) {
|
|
headings = [];
|
|
return;
|
|
}
|
|
const elements = Array.from(article.querySelectorAll<HTMLHeadingElement>('h2, h3'));
|
|
const next: Heading[] = [];
|
|
for (const el of elements) {
|
|
if (!el.id) {
|
|
el.id = el.textContent
|
|
?.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-+|-+$/g, '') ?? '';
|
|
}
|
|
if (!el.id) continue;
|
|
// Inject a hover-revealed `#` anchor link.
|
|
if (!el.querySelector(':scope > .anchor')) {
|
|
const anchor = document.createElement('a');
|
|
anchor.className = 'anchor';
|
|
anchor.href = `#${el.id}`;
|
|
anchor.setAttribute('aria-label', `Anchor link for ${el.textContent ?? ''}`);
|
|
anchor.textContent = '#';
|
|
el.prepend(anchor);
|
|
}
|
|
next.push({
|
|
id: el.id,
|
|
text: (el.textContent ?? '').replace(/^#/, '').trim(),
|
|
level: el.tagName === 'H2' ? 2 : 3
|
|
});
|
|
}
|
|
headings = next;
|
|
setupObserver();
|
|
}
|
|
|
|
function setupObserver() {
|
|
observer?.disconnect();
|
|
if (typeof IntersectionObserver === 'undefined' || headings.length === 0) return;
|
|
observer = new IntersectionObserver(
|
|
(entries) => {
|
|
const visible = entries
|
|
.filter((e) => e.isIntersecting)
|
|
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
|
|
if (visible.length > 0) activeId = visible[0].target.id;
|
|
},
|
|
{ rootMargin: '-80px 0px -70% 0px', threshold: [0, 1] }
|
|
);
|
|
for (const h of headings) {
|
|
const el = document.getElementById(h.id);
|
|
if (el) observer.observe(el);
|
|
}
|
|
}
|
|
|
|
onMount(() => {
|
|
collectHeadings();
|
|
return () => observer?.disconnect();
|
|
});
|
|
|
|
$effect(() => {
|
|
// Re-scan on navigation. `page.url.pathname` is the dependency.
|
|
void page.url.pathname;
|
|
collectHeadings();
|
|
});
|
|
</script>
|
|
|
|
{#if headings.length > 0}
|
|
<nav class="toc" aria-label="On this page">
|
|
<h4>On this page</h4>
|
|
<ul>
|
|
{#each headings as h (h.id)}
|
|
<li class:l3={h.level === 3} class:active={activeId === h.id}>
|
|
<a href="#{h.id}">{h.text}</a>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
</nav>
|
|
{/if}
|
|
|
|
<style>
|
|
.toc {
|
|
font-size: 0.8125rem;
|
|
line-height: 1.6;
|
|
}
|
|
|
|
h4 {
|
|
font-size: 0.7rem;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
color: var(--c-fg-subtle);
|
|
margin: 0 0 0.6rem;
|
|
}
|
|
|
|
ul {
|
|
list-style: none;
|
|
margin: 0;
|
|
padding: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1px;
|
|
border-left: 1px solid var(--c-border);
|
|
}
|
|
|
|
li {
|
|
padding: 0;
|
|
}
|
|
|
|
li.l3 {
|
|
padding-left: 0.75rem;
|
|
}
|
|
|
|
li a {
|
|
display: block;
|
|
padding: 0.25rem 0.75rem;
|
|
color: var(--c-fg-muted);
|
|
border-left: 2px solid transparent;
|
|
margin-left: -1px;
|
|
transition: color 120ms var(--ease), border-color 120ms var(--ease);
|
|
}
|
|
|
|
li a:hover {
|
|
color: var(--c-fg);
|
|
text-decoration: none;
|
|
}
|
|
|
|
li.active a {
|
|
color: var(--c-accent-emphasized);
|
|
border-left-color: var(--c-accent);
|
|
}
|
|
</style>
|