Improve the JS script to highlight the current ToC entry (#1991)

The code relied on an IntersectionOberver, so the ToC was only updated when a heading was in the viewport.
It meant that if we jumped to a part of the text that has no heading, the ToC would still point to the old entry.

The new code looks for the correct heading when the view is scrolled so the correct entry is always selected.

Signed-off-by: Kévin Commaille <zecakeh@tedomum.fr>
pull/1984/head^2
Kévin Commaille 2 weeks ago committed by GitHub
parent 9799b892de
commit b1f66d1b71
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1 @@
Improve the JS script to highlight the current ToC entry.

@ -2,16 +2,9 @@
This template is included at the end of each page's `<body>`. This template is included at the end of each page's `<body>`.
We're using it here to: We're using it here to highlight and scroll the ToC in the sidebar to match
the place we are at in the document.
1) include the JS that generates the table of contents. It would be better
to generate the table of contents as part of the Hugo build process, but
that doesn't work nicely with the way we want to author client-server modules
as separate files.
2) highlight and scroll the ToC in the sidebar to match the place we are at
in the document.
*/}} */}}
<script defer language="javascript" type="text/javascript" src="{{ "js/toc.js" | urlize | relURL }}"></script> <script defer language="javascript" type="text/javascript" src="{{ "js/toc.js" | relURL }}"></script>

@ -15,137 +15,150 @@ limitations under the License.
*/ */
/* /*
Set a new ToC entry. Only call the given function once every 250 milliseconds to avoid impacting
Clear any previously highlighted ToC items, set the new one, the performance of the browser.
and adjust the ToC scroll position. Source: https://remysharp.com/2010/07/21/throttling-function-calls
*/ */
function setTocEntry(newEntry) { function throttle(fn) {
const activeEntries = document.querySelectorAll("#toc a.active"); const threshold = 250;
for (const activeEntry of activeEntries) { let last = null;
activeEntry.classList.remove('active'); let deferTimer = null;
}
return function (...args) {
newEntry.classList.add('active'); const now = new Date();
// don't scroll the sidebar nav if the main content is not scrolled
const nav = document.querySelector("#td-section-nav"); if (last && now < last + threshold) {
const content = document.querySelector("html"); // Hold on to it.
if (content.scrollTop !== 0) { clearTimeout(deferTimer);
nav.scrollTop = newEntry.offsetTop - 100; deferTimer = setTimeout(() => {
last = now;
fn.apply(this, args);
}, threshold);
} else { } else {
nav.scrollTop = 0; last = now;
fn.apply(this, args);
}
} }
} }
/* /*
Test whether a node is in the viewport Get the list of headings that appear in the ToC.
This is not as simple as querying all the headings in the content, because
some headings are not rendered in the ToC (e.g. in the endpoint definitions).
*/ */
function isInViewport(node) { function getHeadings() {
const rect = node.getBoundingClientRect(); let headings = [];
return (
rect.top >= 0 && // First get the anchors in the ToC.
rect.left >= 0 && const toc_anchors = document.querySelectorAll("#toc nav a");
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
rect.right <= (window.innerWidth || document.documentElement.clientWidth) for (const anchor of toc_anchors) {
); // Then get the heading from its selector in the anchor's href.
const selector = anchor.getAttribute("href");
if (!selector) {
console.error("Got ToC anchor without href");
continue;
}
const heading = document.querySelector(selector);
if (!heading) {
console.error("Heading not found for selector:", selector);
continue;
}
headings.push(heading);
}
return headings;
} }
/* /*
The callback we pass to the IntersectionObserver constructor. Get the heading of the text visible at the top of the viewport.
This is the first heading above or at the top of the viewport.
Called when any of our observed nodes starts or stops intersecting
with the viewport.
*/ */
function handleIntersectionUpdate(entries) { function getCurrentHeading(headings, headerOffset) {
const scrollTop = document.documentElement.scrollTop;
/* let prevHeading = null;
Special case: If the current URL hash matches a ToC entry, and let currentHeading = null;
the corresponding heading is visible in the viewport, then that is let index = 0;
made the current ToC entry, and we don't even look at the intersection
observer data. for (const heading of headings) {
This means that if the user has clicked on a ToC entry, // Compute the position compared to the viewport.
we won't unselect it through the intersection observer. const rect = heading.getBoundingClientRect();
*/
const hash = document.location.hash; if (rect.top >= headerOffset && rect.top <= headerOffset + 30) {
if (hash) { // This heading is at the top of the viewport, this is the current heading.
let tocEntryForHash = document.querySelector(`nav li a[href="${hash}"]`); currentHeading = heading;
// if the hash isn't a direct match for a ToC item, check the data attributes break;
if (!tocEntryForHash) {
const fragment = hash.substring(1);
tocEntryForHash = document.querySelector(`nav li a[data-${fragment}]`);
}
if (tocEntryForHash) {
const headingForHash = document.querySelector(hash);
if (headingForHash && isInViewport(headingForHash)) {
setTocEntry(tocEntryForHash);
return;
} }
if (rect.top >= headerOffset) {
// This is in or below the viewport, the current heading should be the
// previous one.
if (prevHeading) {
currentHeading = prevHeading;
} else {
// The first heading does not have a prevHeading.
currentHeading = heading;
} }
break;
} }
let newEntry = null; prevHeading = heading;
index += 1;
for (const entry of entries) { }
if (entry.intersectionRatio > 0) {
const heading = entry.target;
/*
This sidebar nav consists of two sections:
* at the top, a sitenav containing links to other pages
* under that, the ToC for the current page
Since the sidebar scrolls to match the document position, return currentHeading;
the sitenav will tend to scroll off the screen. }
If the user has scrolled up to (or near) the top of the page, /*
we want to show the sitenav so. Select the ToC entry that points to the given ID.
Clear any previously highlighted ToC items, select the new one,
and adjust the ToC scroll position.
*/
function selectTocEntry(id) {
// Deselect previously selected entries.
const activeEntries = document.querySelectorAll("#toc nav a.active");
for (const activeEntry of activeEntries) {
activeEntry.classList.remove('active');
}
So: if the H1 (title) for the current page has started // Find the new entry and select it.
intersecting, then always scroll the sidebar back to the top. const newEntry = document.querySelector(`#toc nav a[href="#${id}"]`);
*/ if (!newEntry) {
if (heading.tagName === "H1" && heading.parentNode.tagName === "DIV") { console.error("ToC entry not found for ID:", id);
const nav = document.querySelector("#td-section-nav");
nav.scrollTop = 0;
return; return;
} }
/* newEntry.classList.add('active');
Otherwise, get the ToC entry for the first entry that
entered the viewport, if there was one.
*/
const id = entry.target.getAttribute('id');
let tocEntry = document.querySelector(`nav li a[href="#${id}"]`);
// if the id isn't a direct match for a ToC item,
// check the ToC entry's `data-*` attributes
if (!tocEntry) {
tocEntry = document.querySelector(`nav li a[data-${id}]`);
}
if (tocEntry && !newEntry) {
newEntry = tocEntry;
}
}
}
if (newEntry) { // Don't scroll the sidebar nav if the main content is not scrolled
setTocEntry(newEntry); const nav = document.querySelector("#td-section-nav");
return; const content = document.querySelector("html");
if (content.scrollTop !== 0) {
nav.scrollTop = newEntry.offsetTop - 100;
} else {
nav.scrollTop = 0;
} }
} }
/* /*
Track when headings enter the viewport, and use this to update the highlight Track when the view is scrolled, and use this to update the highlight for the
for the corresponding ToC entry. corresponding ToC entry.
*/ */
window.addEventListener('DOMContentLoaded', () => { window.addEventListener('DOMContentLoaded', () => {
// Part of the viewport is below the header so we should take it into account.
const toc = document.querySelector("#toc"); const headerOffset = document.querySelector("body > header > nav").clientHeight;
toc.addEventListener("click", event => { const headings = getHeadings();
if (event.target.tagName === "A") {
setTocEntry(event.target); const onScroll = throttle((_e) => {
} // Update the ToC.
let heading = getCurrentHeading(headings, headerOffset);
selectTocEntry(heading.id);
}); });
const observer = new IntersectionObserver(handleIntersectionUpdate); // Initialize the state of the ToC.
onScroll();
document.querySelectorAll("h1, h2, h3, h4, h5, h6").forEach((section) => {
observer.observe(section);
});
// Listen to scroll and resizing changes.
document.addEventListener('scroll', onScroll, false);
document.addEventListener('resize', onScroll, false);
}); });

Loading…
Cancel
Save