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.
svelte-kit-vice/web/routes/active/_components/Toc.svelte

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>

Powered by TurnKey Linux.