Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 69 additions & 21 deletions src/components/sidebarTableOfContents/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,36 @@ function buildTocTree(toc: TocItem[]): TocItem[] {
return items;
}

function getTocItems(main: HTMLElement) {
return Array.from(main.querySelectorAll('h2, h3'))
.map(el => {
const title = el.textContent?.trim();
if (!el.id || !title) {
return null;
}
// This is a relatively new API, that checks if the element is visible in the document
// With this, we filter out e.g. sections hidden via CSS
if (typeof el.checkVisibility === 'function' && !el.checkVisibility()) {
return null;
}
return {
depth: el.tagName === 'H2' ? 2 : 3,
url: `#${el.id}`,
title,
element: el,
isActive: false,
};
})
.filter(isNotNil);
}

function getMainElement() {
if (typeof document === 'undefined') {
return null;
}
return document.getElementById('main');
}

// The full, rendered page is required in order to generate the table of
// contents since headings can come from child components, included MDX files,
// etc. Even though this should hypothetically be doable on the server, methods
Expand All @@ -83,33 +113,51 @@ export function SidebarTableOfContents() {

// gather the toc items on mount
useEffect(() => {
if (typeof document === 'undefined') {
const main = getMainElement();
if (!main) {
return;
}
const main = document.getElementById('main');

setTocItems(getTocItems(main));
}, []);

// ensure toc items are kept up-to-date if the DOM changes
useEffect(() => {
const main = getMainElement();
if (!main) {
throw new Error('#main element not found');
return () => {};
}
const tocItems_ = Array.from(main.querySelectorAll('h2, h3'))
.map(el => {
const title = el.textContent?.trim() ?? '';
if (!el.id) {
return null;
}
return {
depth: el.tagName === 'H2' ? 2 : 3,
url: `#${el.id}`,
title,
element: el,
isActive: false,
};
})
.filter(isNotNil);
setTocItems(tocItems_);
}, []);

const observer = new MutationObserver(() => {
const newTocItems = getTocItems(main);

// Avoid flashing sidebar elements if nothing changes
if (
newTocItems.length === tocItems.length &&
newTocItems.every((item, index) => item.url === tocItems[index].url)
) {
return;
}
setTocItems(newTocItems);
});

// Start observing the target node for any changes in its subtree
// We only care about:
// * Children being added/removed (childList)
// Any id, class, or style attribute being changed (this approximates CSS changes)
observer.observe(main, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['class', 'id', 'style'],
});

return () => observer.disconnect();
}, [tocItems]);

// Mark the active item based on the scroll position
useEffect(() => {
if (tocItems.length === 0) {
if (!tocItems.length) {
return () => {};
}
// account for the header height
Expand Down
Loading