src/Elastic.Markdown/Assets/toc-nav.ts (138 lines of code) (raw):
import { $$, $ } from 'select-dom'
interface TocElements {
headings: Element[]
tocLinks: HTMLAnchorElement[]
tocContainer: HTMLDivElement | null
progressIndicator: HTMLDivElement
}
// 34 is the height of the header + some padding
// 4 is the base spacing unit
const HEADING_OFFSET = 34 * 4
function initializeTocElements(): TocElements {
const headings = $$('#markdown-content h2, #markdown-content h3')
const tocLinks = $$('#toc-nav li>a') as HTMLAnchorElement[]
const tocContainer = $('#toc-nav .toc-progress-container') as HTMLDivElement
const progressIndicator = $(
'.toc-progress-indicator',
tocContainer
) as HTMLDivElement
return { headings, tocLinks, tocContainer, progressIndicator }
}
// Find the current TOC links based on visible headings
// It can return multiple links because headings in a tab can have the same position
function findCurrentTocLinks(elements: TocElements): HTMLAnchorElement[] {
let currentTocLinks: HTMLAnchorElement[] = []
let currentTop: number | null = null
for (const heading of elements.headings) {
const rect = heading.getBoundingClientRect()
if (rect.top <= HEADING_OFFSET) {
if (currentTop !== null && Math.abs(rect.top - currentTop) > 1) {
currentTocLinks = []
}
currentTop = rect.top
const foundLink = elements.tocLinks.find(
(link) =>
link.getAttribute('href') ===
`#${heading.closest('.heading-wrapper')?.id}`
)
if (foundLink) {
currentTocLinks.push(foundLink)
}
}
}
return currentTocLinks
}
// Get visible headings in viewport
function getVisibleHeadings(elements: TocElements) {
return elements.headings.filter((heading) => {
const rect = heading.getBoundingClientRect()
return (
rect.top - HEADING_OFFSET + 64 >= 0 &&
rect.top <= window.innerHeight
)
})
}
// If the user has scrolled to the bottom of the page,
// and there are still multiple headings visible, we need to
// handle the progress indicator differently.
// In this case it sets the indicator for all visible headings.
function handleBottomScroll(elements: TocElements) {
const visibleHeadings = getVisibleHeadings(elements)
if (visibleHeadings.length === 0) return
const firstHeading = visibleHeadings[0]
const lastHeading = visibleHeadings[visibleHeadings.length - 1]
const firstLink = elements.tocLinks
.find(
(link) =>
link.getAttribute('href') ===
`#${firstHeading.parentElement?.id}`
)
?.closest('li')
const lastLink = elements.tocLinks
.find(
(link) =>
link.getAttribute('href') ===
`#${lastHeading.parentElement?.id}`
)
?.closest('li')
if (firstLink && lastLink && elements.tocContainer) {
const tocRect = elements.tocContainer.getBoundingClientRect()
const firstRect = firstLink.getBoundingClientRect()
const lastRect = lastLink.getBoundingClientRect()
updateProgressIndicatorPosition(
elements.progressIndicator,
firstRect.top - tocRect.top,
lastRect.top + lastRect.height - firstRect.top
)
}
}
function updateProgressIndicatorPosition(
indicator: HTMLDivElement,
top: number,
height: number
) {
indicator.style.top = `${top}px`
indicator.style.height = `${height}px`
}
function updateIndicator(elements: TocElements) {
if (!elements.tocContainer) return
const isAtBottom =
window.innerHeight + window.scrollY >=
document.documentElement.scrollHeight - 10
const currentTocLinks = findCurrentTocLinks(elements)
if (isAtBottom) {
handleBottomScroll(elements)
} else if (currentTocLinks.length > 0) {
const tocRect = elements.tocContainer.getBoundingClientRect()
const linkElements = currentTocLinks
.map((link) => link.closest('li'))
.filter((li): li is HTMLLIElement => li !== null)
if (linkElements.length === 0) return
const firstLinkRect = linkElements[0].getBoundingClientRect()
const lastLinkRect =
linkElements[linkElements.length - 1].getBoundingClientRect()
updateProgressIndicatorPosition(
elements.progressIndicator,
firstLinkRect.top - tocRect.top,
lastLinkRect.top + lastLinkRect.height - firstLinkRect.top
)
}
}
function setupSmoothScrolling(elements: TocElements) {
elements.tocLinks.forEach((link) => {
link.addEventListener('click', (e) => {
const href = link.getAttribute('href')
if (href?.charAt(0) === '#') {
const target = document.getElementById(href.slice(1))
if (target) {
e.preventDefault()
target.scrollIntoView({ behavior: 'smooth' })
history.pushState(null, '', href)
}
}
})
})
}
export function initTocNav() {
const elements = initializeTocElements()
if (elements.progressIndicator != null) {
elements.progressIndicator.style.height = '0'
elements.progressIndicator.style.top = '0'
}
const update = () => updateIndicator(elements)
update()
window.addEventListener('scroll', update, { passive: true })
window.addEventListener('resize', update, { passive: true })
setupSmoothScrolling(elements)
}