131 lines
3.6 KiB
Svelte
131 lines
3.6 KiB
Svelte
<script lang="ts">
|
|
import { createRunWithCleanup } from "$lib/utils/run-with-cleanup";
|
|
import { onMount } from "svelte";
|
|
import TocItems, { type TocItem } from "./TocItems.svelte";
|
|
|
|
export let el: HTMLElement | null;
|
|
export let rootMargin = "0% 0% -80% 0%";
|
|
|
|
let runWithCleanup = createRunWithCleanup();
|
|
let activeId: string | undefined;
|
|
let items: TocItem[] = [];
|
|
let idToItem: { [id: string]: TocItem } = {};
|
|
let visibleIds = new Set<string>();
|
|
|
|
$: flatItems = flattenItems(items);
|
|
|
|
function offsetItemById(id: string, offset: number): TocItem | undefined {
|
|
let item = idToItem[id];
|
|
if (item) {
|
|
return flatItems[item.index + offset];
|
|
}
|
|
}
|
|
|
|
function computeActiveId() {
|
|
if (visibleIds.size === 0) {
|
|
activeId = undefined;
|
|
return;
|
|
}
|
|
activeId = Array.from(visibleIds)
|
|
.map((id) => idToItem[id]!)
|
|
.reduce((a, b) => (a.index < b.index ? a : b))?.id;
|
|
}
|
|
|
|
let observer: IntersectionObserver | null = null;
|
|
onMount(() => {
|
|
observer = new IntersectionObserver(
|
|
(entries) => {
|
|
entries.forEach((entry) => {
|
|
if (entry.isIntersecting) {
|
|
visibleIds.add(entry.target.id);
|
|
computeActiveId();
|
|
} else if (visibleIds.has(entry.target.id)) {
|
|
visibleIds.delete(entry.target.id);
|
|
if (visibleIds.size > 0) {
|
|
computeActiveId();
|
|
} else {
|
|
let scrollingDown =
|
|
entry.boundingClientRect.y < (entry.rootBounds?.y ?? 0);
|
|
// If scrolling down, this one should remain active: we're still in the contents of it
|
|
// until we get to the next item below.
|
|
// If scrolling up, we should go to the previous item.
|
|
if (!scrollingDown) {
|
|
activeId = offsetItemById(entry.target.id, -1)?.id;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
},
|
|
{ rootMargin }
|
|
);
|
|
});
|
|
|
|
function generateItems(root: HTMLElement, ignoreH1 = true) {
|
|
let headings = Array.from(
|
|
root.querySelectorAll(`${ignoreH1 ? "" : "h1, "}h2, h3, h4, h5, h6`)
|
|
);
|
|
let newItems: TocItem[] = [];
|
|
let index = 0;
|
|
for (const heading of headings) {
|
|
let headingLevel = parseInt(heading.tagName[1]);
|
|
const newItem = {
|
|
headingLevel,
|
|
index: index++,
|
|
id: heading.id,
|
|
url: "#" + heading.id,
|
|
title: heading.textContent || "",
|
|
};
|
|
idToItem[newItem.id] = newItem;
|
|
let parentItems: TocItem[] = newItems;
|
|
while (
|
|
parentItems.length > 0 &&
|
|
parentItems[parentItems.length - 1].headingLevel < headingLevel
|
|
) {
|
|
let child = parentItems[parentItems.length - 1];
|
|
child.items = child.items || [];
|
|
parentItems = child.items;
|
|
}
|
|
parentItems.push(newItem);
|
|
}
|
|
return newItems;
|
|
}
|
|
|
|
$: items = el ? generateItems(el) : [];
|
|
|
|
function flattenItems(itemList?: TocItem[]) {
|
|
let result: TocItem[] = [];
|
|
if (!itemList) {
|
|
return result;
|
|
}
|
|
for (let item of itemList) {
|
|
result = result.concat([item], flattenItems(item.items));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
$: runWithCleanup(() => {
|
|
if (!observer) {
|
|
return;
|
|
}
|
|
let ids = flatItems.map((item) => item.id);
|
|
ids.forEach((id) => {
|
|
let element = document.getElementById(id);
|
|
if (element) {
|
|
observer!.observe(element);
|
|
}
|
|
});
|
|
return () => {
|
|
ids.forEach((id) => {
|
|
let element = document.getElementById(id);
|
|
if (element) {
|
|
observer!.unobserve(element);
|
|
}
|
|
});
|
|
};
|
|
}, "observer");
|
|
</script>
|
|
|
|
<nav>
|
|
<TocItems {items} {activeId} />
|
|
</nav>
|