Skip to content

Commit 09b58f8

Browse files
committed
Ensure that the TOC link that the user clicked is activated
1 parent 38d46a0 commit 09b58f8

File tree

1 file changed

+58
-81
lines changed

1 file changed

+58
-81
lines changed

src/pydata_sphinx_theme/assets/scripts/pydata-sphinx-theme.js

Lines changed: 58 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -988,62 +988,57 @@ async function fetchRevealBannersTogether() {
988988
*
989989
*/
990990
async function addTOCScrollSpy() {
991+
// Intersection observer options
991992
const options = {
992-
root: null, // Target the viewport
993-
// Offset the rootMargin slightly so that intersections trigger _before_ headings begin to
994-
// go offscreen. Need to account for the height of the top menu bar which is sticky, and
995-
// obscures the top part of the viewport. The factor of -1 is to move the location where the
996-
// intersection triggers downward below this element; a factor of 2 was found to give
997-
// good results in testing. If this is only -1 (the exact size of the top menu bar),
998-
// the intersection doesn't trigger until the heading is scrolled off the top of the screen
999-
// behind the menu bar, which also means that when clicking on a link in the TOC doesn't
1000-
// trigger the intersection (and therefore doesn't highlight the correct heading).
1001-
rootMargin: `${-2 * document.querySelector("header.bd-header").getBoundingClientRect().bottom}px 0px 0px 0px`,
1002-
threshold: 0, // Trigger as soon as it becomes visible or invisible
993+
root: null,
994+
rootMargin: `0px 0px -70% 0px`, // Use -70% for the bottom margin to so that intersection events happen in only the top third of the viewport
995+
threshold: 1, // Trigger once the heading becomes fully visible within the area described by the root margin
1003996
};
1004997

998+
// Right sidebar table of contents container
1005999
const pageToc = document.querySelector("#pst-page-toc-nav");
1006-
const tocLinks = Array.from(document.querySelectorAll("#pst-page-toc-nav a"));
1000+
1001+
// The table of contents is a list of .toc-entry items each of which contains
1002+
// a link and possibly a nested list representing one level deeper in the
1003+
// table of contents.
1004+
const tocEntries = Array.from(pageToc.querySelectorAll(".toc-entry"));
1005+
const tocLinks = Array.from(pageToc.querySelectorAll("a"));
1006+
1007+
// When the website visitor clicks a link in the TOC, we want that link to be
1008+
// highlighted/activated, NOT whichever TOC link the intersection observer
1009+
// callback would otherwise highlight, so we turn off the observer and turn it
1010+
// back on later.
1011+
let disableObserver = false;
1012+
pageToc.addEventListener("click", (event) => {
1013+
disableObserver = true;
1014+
const clickedTocLink = tocLinks.find((el) => el.contains(event.target));
1015+
activate(clickedTocLink);
1016+
setTimeout(() => {
1017+
// Give the page ample time to finish scrolling, then re-enable the
1018+
// intersection observer.
1019+
disableObserver = false;
1020+
}, 1500);
1021+
});
10071022

10081023
/**
1009-
* Activate an element and its parent TOC dropdowns; deactivate
1010-
* everything else in the TOC. Together with the theme CSS, this
1011-
* highlights the given TOC entry.
1024+
* Activate an element and its chain of ancestor TOC entries; deactivate
1025+
* everything else in the TOC. Together with the theme CSS, this unfolds
1026+
* the TOC out to the given entry and highlights that entry.
10121027
*
1013-
* @param {HTMLElement} tocElement The TOC entry to be highlighted
1028+
* @param {HTMLElement} tocLink The TOC entry to be highlighted
10141029
*/
1015-
function activate(tocElement) {
1016-
// Deactivate all TOC links except the requested element
1017-
tocLinks
1018-
.filter((el) => el !== tocElement)
1019-
.forEach((el) => {
1030+
function activate(tocLink) {
1031+
tocLinks.forEach((el) => {
1032+
if (el === tocLink) {
1033+
el.classList.add("active");
1034+
el.setAttribute("aria-current", true);
1035+
} else {
10201036
el.classList.remove("active");
10211037
el.removeAttribute("aria-current");
1022-
});
1023-
1024-
// Activate the requested element
1025-
tocElement.classList.add("active");
1026-
tocElement.setAttribute("aria-current", "true");
1027-
1028-
// Travel up the DOM from the requested element, collecting the set of
1029-
// all parent elements that need to be activated. These are the collapsible
1030-
// <li> elements that can hold nested child headings.
1031-
const parents = new Set();
1032-
let el = tocElement.parentElement;
1033-
while (el && el !== pageToc) {
1034-
if (el.matches(".toc-entry")) {
1035-
parents.add(el);
10361038
}
1037-
el = el.parentElement;
1038-
}
1039-
1040-
// Iterate over all child elements of the TOC, deactivating everything
1041-
// that isn't a parent of the active node and activating the parents
1042-
// of the active TOC entry. This closes all collapsible <li> elements
1043-
// of which the active element is not a direct descendent, and activates
1044-
// those which are.
1045-
pageToc.querySelectorAll(".toc-entry").forEach((el) => {
1046-
if (parents.has(el)) {
1039+
});
1040+
tocEntries.forEach((el) => {
1041+
if (el.contains(tocLink)) {
10471042
el.classList.add("active");
10481043
} else {
10491044
el.classList.remove("active");
@@ -1052,61 +1047,43 @@ async function addTOCScrollSpy() {
10521047
}
10531048

10541049
/**
1055-
* Get the heading in the article associated with a TOC entry.
1050+
* Get the heading in the article associated with the link in the table of contents
10561051
*
1057-
* @param {HTMLElement} tocElement TOC DOM element to use to grab an article heading
1052+
* @param {HTMLElement} tocLink TOC DOM element to use to grab an article heading
10581053
*
10591054
* @returns The article heading that the TOC element links to
10601055
*/
1061-
function getHeading(tocElement) {
1056+
function getHeading(tocLink) {
10621057
return document.querySelector(
1063-
`${tocElement.getAttribute("href")} > :is(h1,h2,h3,h4,h5,h6)`,
1058+
`${tocLink.getAttribute("href")} > :is(h1,h2,h3,h4,h5,h6)`,
10641059
);
10651060
}
10661061

1067-
// Create a hashmap which stores the state of the headings. This object maps headings
1068-
// in the article to TOC elements, along with information about whether they are
1069-
// visible and the order in which they appear in the article.
1070-
const headingState = new Map(
1071-
Array.from(tocLinks).map((el) => {
1072-
return [
1073-
getHeading(el),
1074-
{
1075-
tocElement: el,
1076-
visible: false,
1077-
},
1078-
];
1079-
}),
1080-
);
1062+
// Map article headings to their associated TOC links
1063+
const tocLinksByHeading = new Map();
1064+
tocLinks.forEach((link) => tocLinksByHeading.set(getHeading(link), link));
10811065

10821066
/**
10831067
*
1084-
* @param {IntersectionObserverEntry} entries Objects containing threshold-crossing
1068+
* @param {IntersectionObserverEntry[]} entries Objects containing threshold-crossing
10851069
* event information
10861070
*
10871071
*/
10881072
function callback(entries) {
1089-
// Update the state of the TOC headings
1090-
entries.forEach((entry) => {
1091-
headingState.get(entry.target).visible = entry.isIntersecting;
1092-
});
1093-
1094-
// If there are any visible results, activate the one _above_ the first visible
1095-
// heading. This ensures that when a heading scrolls offscreen, the TOC entry
1096-
// for that entry remains highlighted.
1097-
//
1098-
// If the first element is visible, just highlight the first entry in the TOC.
1099-
const visible = Array.from(headingState.values()).filter(
1100-
({ visible }) => visible,
1101-
);
1102-
if (visible.length > 0) {
1103-
const indexAbove = Math.max(sorted[0].index - 1, 0);
1104-
activate(tocLinks[indexAbove]);
1073+
if (disableObserver) {
1074+
return;
1075+
}
1076+
const entry = entries.filter((entry) => entry.isIntersecting).pop();
1077+
if (!entry) {
1078+
return;
11051079
}
1080+
const heading = entry.target;
1081+
const tocLink = tocLinksByHeading.get(heading);
1082+
activate(tocLink);
11061083
}
11071084

11081085
const observer = new IntersectionObserver(callback, options);
1109-
headingState.forEach((_, heading) => {
1086+
tocLinksByHeading.keys().forEach((heading) => {
11101087
observer.observe(heading);
11111088
});
11121089
}

0 commit comments

Comments
 (0)