scrollspy.ts 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131
  1. // Implements a scroll spy system for the ToC, displaying the current section with an indicator and scrolling to it when needed.
  2. // Inspired from https://gomakethings.com/debouncing-your-javascript-events/
  3. function debounced(func: Function) {
  4. let timeout;
  5. return () => {
  6. if (timeout) {
  7. window.cancelAnimationFrame(timeout);
  8. }
  9. timeout = window.requestAnimationFrame(() => func());
  10. }
  11. }
  12. const headersQuery = ".article-content h1[id], .article-content h2[id], .article-content h3[id], .article-content h4[id], .article-content h5[id], .article-content h6[id]";
  13. const tocQuery = "#TableOfContents";
  14. const navigationQuery = "#TableOfContents li";
  15. const activeClass = "active-class";
  16. function scrollToTocElement(tocElement: HTMLElement, scrollableNavigation: HTMLElement) {
  17. let textHeight = tocElement.querySelector("a").offsetHeight;
  18. let scrollTop = tocElement.offsetTop - scrollableNavigation.offsetHeight / 2 + textHeight / 2 - scrollableNavigation.offsetTop;
  19. if (scrollTop < 0) {
  20. scrollTop = 0;
  21. }
  22. scrollableNavigation.scrollTo({ top: scrollTop, behavior: "smooth" });
  23. }
  24. type IdToElementMap = { [key: string]: HTMLElement };
  25. function buildIdToNavigationElementMap(navigation: NodeListOf<Element>): IdToElementMap {
  26. const sectionLinkRef: IdToElementMap = {};
  27. navigation.forEach((navigationElement: HTMLElement) => {
  28. const link = navigationElement.querySelector("a");
  29. const href = link.getAttribute("href");
  30. if (href.startsWith("#")) {
  31. sectionLinkRef[href.slice(1)] = navigationElement;
  32. }
  33. });
  34. return sectionLinkRef;
  35. }
  36. function computeOffsets(headers: NodeListOf<Element>) {
  37. let sectionsOffsets = [];
  38. headers.forEach((header: HTMLElement) => { sectionsOffsets.push({ id: header.id, offset: header.offsetTop }) });
  39. sectionsOffsets.sort((a, b) => a.offset - b.offset);
  40. return sectionsOffsets;
  41. }
  42. function setupScrollspy() {
  43. let headers = document.querySelectorAll(headersQuery);
  44. if (!headers) {
  45. console.warn("No header matched query", headers);
  46. return;
  47. }
  48. let scrollableNavigation = document.querySelector(tocQuery) as HTMLElement | undefined;
  49. if (!scrollableNavigation) {
  50. console.warn("No toc matched query", tocQuery);
  51. return;
  52. }
  53. let navigation = document.querySelectorAll(navigationQuery);
  54. if (!navigation) {
  55. console.warn("No navigation matched query", navigationQuery);
  56. return;
  57. }
  58. let sectionsOffsets = computeOffsets(headers);
  59. // We need to avoid scrolling when the user is actively interacting with the ToC. Otherwise, if the user clicks on a link in the ToC,
  60. // we would scroll their view, which is not optimal usability-wise.
  61. let tocHovered: boolean = false;
  62. scrollableNavigation.addEventListener("mouseenter", debounced(() => tocHovered = true));
  63. scrollableNavigation.addEventListener("mouseleave", debounced(() => tocHovered = false));
  64. let activeSectionLink: Element;
  65. let idToNavigationElement: IdToElementMap = buildIdToNavigationElementMap(navigation);
  66. function scrollHandler() {
  67. let scrollPosition = document.documentElement.scrollTop || document.body.scrollTop;
  68. let newActiveSection: HTMLElement | undefined;
  69. // Find the section that is currently active.
  70. // It is possible for no section to be active, so newActiveSection may be undefined.
  71. sectionsOffsets.forEach((section) => {
  72. if (scrollPosition >= section.offset - 20) {
  73. newActiveSection = document.getElementById(section.id);
  74. }
  75. });
  76. // Find the link for the active section. Once again, there are a few edge cases:
  77. // - No active section = no link => undefined
  78. // - No active section but the link does not exist in toc (e.g. because it is outside of the applicable ToC levels) => undefined
  79. let newActiveSectionLink: HTMLElement | undefined
  80. if (newActiveSection) {
  81. newActiveSectionLink = idToNavigationElement[newActiveSection.id];
  82. }
  83. if (newActiveSection && !newActiveSectionLink) {
  84. // The active section does not have a link in the ToC, so we can't scroll to it.
  85. console.debug("No link found for section", newActiveSection);
  86. } else if (newActiveSectionLink !== activeSectionLink) {
  87. if (activeSectionLink)
  88. activeSectionLink.classList.remove(activeClass);
  89. if (newActiveSectionLink) {
  90. newActiveSectionLink.classList.add(activeClass);
  91. if (!tocHovered) {
  92. // Scroll so that newActiveSectionLink is in the middle of scrollableNavigation, except when it's from a manual click (hence the tocHovered check)
  93. scrollToTocElement(newActiveSectionLink, scrollableNavigation);
  94. }
  95. }
  96. activeSectionLink = newActiveSectionLink;
  97. }
  98. }
  99. window.addEventListener("scroll", debounced(scrollHandler));
  100. // Resizing may cause the offset values to change: recompute them.
  101. function resizeHandler() {
  102. sectionsOffsets = computeOffsets(headers);
  103. scrollHandler();
  104. }
  105. window.addEventListener("resize", debounced(resizeHandler));
  106. }
  107. export { setupScrollspy };