feat: client-side TOC widget with Alpine.js scroll spy

Replace the server-side toc.njk placeholder (which never rendered because
no code populated the `toc` variable) with a client-side Alpine.js component
that scans .e-content headings at page load, builds a dynamic table of
contents, and highlights the current section via IntersectionObserver.

- Only appears on articles/notes with 3+ headings (h2-h4)
- Excluded at build time for bookmarks, likes, and reposts
- Scroll spy activates heading in top 30% of viewport

Confab-Link: http://localhost:8080/sessions/cc343b15-8d10-43cd-a48f-ca912eb79b83
This commit is contained in:
Ricardo
2026-03-10 13:01:53 +01:00
parent 508ddf03ca
commit 48160a5b13
3 changed files with 72 additions and 17 deletions

View File

@@ -1,19 +1,27 @@
{# Table of Contents Widget (for articles with headings) #}
{% if toc and toc.length %}
<is-land on:visible>
{# Table of Contents Widget — client-side Alpine.js heading scanner with scroll spy #}
{# Only renders on Articles/Notes with 3+ headings (h2-h4) #}
{% if title or (not (bookmarkOf or bookmark_of or likeOf or like_of or repostOf or repost_of)) %}
<div x-data="tocScanner" x-show="items.length > 0" x-cloak>
<div class="widget">
<h3 class="widget-title">Contents</h3>
<nav class="toc" aria-label="Table of contents">
<ul class="space-y-1 text-sm">
{% for item in toc %}
<li class="{% if item.level == 3 %}ml-3{% elif item.level == 4 %}ml-6{% elif item.level == 5 %}ml-9{% elif item.level == 6 %}ml-12{% endif %}">
<a href="#{{ item.slug }}" class="text-surface-600 dark:text-surface-400 hover:text-accent-600 dark:hover:text-accent-400 hover:underline transition-colors">
{{ item.text }}
</a>
<template x-for="item in items" :key="item.id">
<li :class="{
'ml-3': item.level === 3,
'ml-6': item.level === 4
}">
<a :href="'#' + item.id"
x-text="item.text"
class="transition-colors hover:underline"
:class="item.active
? 'text-accent-600 dark:text-accent-400 font-medium'
: 'text-surface-600 dark:text-surface-400 hover:text-accent-600 dark:hover:text-accent-400'"
></a>
</li>
{% endfor %}
</template>
</ul>
</nav>
</div>
</is-land>
</div>
{% endif %}

View File

@@ -79,6 +79,7 @@
<script src="/js/comments.js?v={{ '/js/comments.js' | hash }}" defer></script>
<script src="/js/fediverse-interact.js?v={{ '/js/fediverse-interact.js' | hash }}" defer></script>
<script src="/js/lightbox.js?v={{ '/js/lightbox.js' | hash }}" defer></script>
<script src="/js/toc-scanner.js?v={{ '/js/toc-scanner.js' | hash }}" defer></script>
<script defer src="/js/vendor/alpine-collapse.min.js?v={{ '/js/vendor/alpine-collapse.min.js' | hash }}"></script>
<script defer src="/js/vendor/alpine.min.js?v={{ '/js/vendor/alpine.min.js' | hash }}"></script>
<style>[x-cloak] { display: none !important; }</style>

46
js/toc-scanner.js Normal file
View File

@@ -0,0 +1,46 @@
/**
* Alpine.js TOC scanner component.
* Scans .e-content for h2/h3/h4 headings with IDs,
* builds a table of contents, and highlights the
* current section via IntersectionObserver scroll spy.
*/
document.addEventListener("alpine:init", () => {
Alpine.data("tocScanner", () => ({
items: [],
_observer: null,
init() {
const content = document.querySelector(".e-content");
if (!content) return;
const headings = content.querySelectorAll("h2[id], h3[id], h4[id]");
if (headings.length < 3) return;
this.items = Array.from(headings).map((h) => ({
id: h.id,
text: h.textContent.replace(/^#\s*/, "").trim(),
level: parseInt(h.tagName[1]),
active: false,
}));
this._observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
this.items.forEach((item) => {
item.active = item.id === entry.target.id;
});
}
}
},
{ rootMargin: "0px 0px -70% 0px" },
);
headings.forEach((h) => this._observer.observe(h));
},
destroy() {
if (this._observer) this._observer.disconnect();
},
}));
});