Files
blog/src/components/Toc.vue

164 lines
4.6 KiB
Vue

<script setup lang="ts">
import type { MarkdownHeading } from 'astro';
import { onBeforeUnmount, onMounted, ref } from 'vue';
const props = defineProps<{ headings: MarkdownHeading[] }>();
// headings deeper than h3 don't display well because they are too deeply indented
const headings = props.headings.filter(h => h.depth <= 3);
// for each heading slug, track whether the corresponding heading is above the cutoff point
// (the cutoff point being a hypothetical line 2/3 of the way up the viewport)
let headingStatuses = Object.fromEntries(headings.map(h => ([h.slug, false])));
// we need to store a reference to the observer so we can dispose of it on resize/unmount
let headingObserver: IntersectionObserver | null = null;
// the final slug that should be highlighted as "current" in the TOC
let currentSlug = ref('');
function handleIntersectionUpdate(entries: IntersectionObserverEntry[], headingElems: HTMLElement[]) {
for (const entry of entries) {
const slug = entry.target.id;
const status = entry.isIntersecting;
headingStatuses[slug] = status;
}
// headings are in DOM order, so this gives us the last heading that's still above the cutoff point
for (const elem of headingElems) {
if (headingStatuses[elem.id]) {
currentSlug.value = elem.id;
}
else {
break;
}
}
}
function setupObserver() {
// if there was already an observer, turn it off
if (headingObserver) {
headingObserver.disconnect();
}
const headingElems = headings.map(h => document.getElementById(h.slug)!);
const obs = new IntersectionObserver(
entries => handleIntersectionUpdate(entries, headingElems),
// top margin equal to body height means that the intersection zone extends up beyond
// the top of the document, i.e. elements can only enter/leave the zone at the bottom
{ rootMargin: `${document.body.clientHeight}px 0px -66% 0px` },
);
for (const elem of headingElems) {
obs.observe(elem);
}
headingObserver = obs;
}
onMounted(() => {
// create the observer once on component startup
setupObserver();
// any time the window resizes, the document height could change, so we need to recreate it
window.addEventListener('resize', setupObserver);
});
onBeforeUnmount(() => {
window.removeEventListener('resize', setupObserver);
headingObserver?.disconnect();
});
</script>
<template>
<div id="toc">
<h5>C<span class="lower">ontents</span></h5>
<ul id="toc-list">
<li
v-for="heading in headings"
:data-current="heading.slug == currentSlug"
:style="`--depth: ${heading.depth}`"
>
<span v-show="heading.slug == currentSlug" class="marker"></span>
<a :href="`#${heading.slug}`">
{{ heading.text }}
</a>
</li>
</ul>
</div>
</template>
<style scoped>
#toc {
position: sticky;
top: 1.5rem;
margin-left: 1rem;
margin-right: 4rem;
max-width: 18rem;
font-size: var(--content-size-sm);
color: var(--content-color-faded);
/*
minimum desirable TOC width is 8rem
add 4rem for margins, giving total gutter width of 12.5rem
multiply by 2 since there are two equally-sized gutters, then add content-width (52.5rem)
*/
@media(max-width: 77.5rem) {
display: none;
}
}
h5 {
font-variant: petite-caps;
font-weight: 350;
font-size: var(--content-size);
font-family: 'Figtree Variable';
color: var(--content-color-faded);
max-width: fit-content;
margin-top: 0;
margin-bottom: 0.35em;
/*padding-bottom: 0.25em;*/
border-bottom: 1px solid currentcolor;
/* make the border stretch beyond the text just a bit, because I like the effect */
padding-right: 1.5rem;
/* SmallCaps is an Astro component so we can't use it here, but we can fake it */
& .lower {
font-weight: 500;
}
}
li {
position: relative;
margin-top: 0.45em;
margin-left: calc(0.75em * (var(--depth) - 2));
font-size: var(--content-size-sm);
/* make sure that one item wrapped across multiple lines doesn't just look like multiple items */
line-height: 1.15;
&[data-current="true"], &:hover {
color: var(--content-color);
}
}
.marker {
position: absolute;
left: -0.6rem;
top: 0.05em;
bottom: 0.2em;
width: 0.125rem;
background-color: var(--accent-color);
}
a {
color: inherit;
text-decoration: none;
}
ul {
margin: 0;
padding: 0;
list-style: none;
}
</style>