Files
Ryan Kes 36b6a4551c 🐛 Fix smartTOC never highlighting on a page that can't scroll
getActiveAnchorId derives the active entry from scroll position alone,
so on a page short enough that scrollHeight never exceeds
window.innerHeight, the answer can never change. Clicking a TOC entry
still updates location.hash, but nothing ever picks it up as active.

Falls back to the hash when the page can't scroll, honoured only if
it names an entry the TOC actually lists. Pages that do scroll are
unaffected - this only fires when scroll position can't answer the
question at all.

Fixes #3087
2026-09-13 22:12:55 +01:00

141 lines
5.3 KiB
HTML

<details
open
id="TOCView"
class="toc-right mt-0 overflow-y-auto overscroll-contain bf-scrollbar rounded-lg -ms-5 ps-5 pe-2 hidden lg:block">
<summary
class="block py-1 text-lg font-semibold cursor-pointer bg-neutral-100 text-neutral-800 -ms-5 ps-5 dark:bg-neutral-700 dark:text-neutral-100 lg:hidden">
{{ i18n "article.table_of_contents" }}
</summary>
<div
class="min-w-[220px] py-2 border-dotted border-s-1 -ms-5 ps-5 dark:border-neutral-600">
{{ replace (.TableOfContents | emojify) `id="TableOfContents"` (printf `id="TableOfContents" aria-label="%s"` (i18n "article.table_of_contents")) | safeHTML }}
</div>
</details>
<details class="toc-inside mt-0 overflow-hidden rounded-lg -ms-5 ps-5 lg:hidden">
<summary
class="py-1 text-lg font-semibold cursor-pointer bg-neutral-100 text-neutral-800 -ms-5 ps-5 dark:bg-neutral-700 dark:text-neutral-100 lg:hidden">
{{ i18n "article.table_of_contents" }}
</summary>
<div
class="py-2 border-dotted border-neutral-300 border-s-1 -ms-5 ps-5 dark:border-neutral-600">
{{ replace (.TableOfContents | emojify) `id="TableOfContents"` (printf `id="TableOfContentsMobile" aria-label="%s"` (i18n "article.table_of_contents")) | safeHTML }}
</div>
</details>
{{ if .Site.Params.smartTOC }}
<script>
(function () {
'use strict'
const SCROLL_OFFSET_RATIO = 0.33
const TOC_SELECTOR = '#TableOfContents'
const ANCHOR_SELECTOR = '.anchor'
const TOC_LINK_SELECTOR = 'a[href^="#"]'
const NESTED_LIST_SELECTOR = 'li ul'
const ACTIVE_CLASS = 'active'
let isJumpingToAnchor = false
function getActiveAnchorId(anchors, offsetRatio) {
// On a page short enough that it never scrolls, scroll position can
// never change, so the loop below can never pick a different entry.
// Clicking a TOC entry still changes the hash, so fall back to that
// when scrolling can't answer the question at all - honoured only if
// it names an entry the TOC actually lists, so an unrelated hash
// doesn't fake a match. Pages that do scroll are unaffected.
const canScroll =
document.documentElement.scrollHeight > window.innerHeight + 1
if (!canScroll) {
const fromHash = decodeURIComponent(window.location.hash.substring(1))
const known = [...document.querySelectorAll('#TableOfContents a[href^="#"]')]
.map(link => decodeURIComponent(link.getAttribute('href').substring(1)))
if (fromHash && known.includes(fromHash)) return fromHash
}
const threshold = window.scrollY + window.innerHeight * offsetRatio
const tocLinks = [...document.querySelectorAll('#TableOfContents a[href^="#"]')]
const tocIds = new Set(tocLinks.map(link => link.getAttribute('href').substring(1)))
if (isJumpingToAnchor) {
for (let i = 0; i < anchors.length; i++) {
const anchor = anchors[i]
if (!tocIds.has(anchor.id)) continue
const top = anchor.getBoundingClientRect().top + window.scrollY
if (Math.abs(window.scrollY - top) < 100) {
return anchor.id
}
}
}
for (let i = anchors.length - 1; i >= 0; i--) {
const top = anchors[i].getBoundingClientRect().top + window.scrollY
if (top <= threshold && tocIds.has(anchors[i].id)) {
return anchors[i].id
}
}
return anchors.find(anchor => tocIds.has(anchor.id))?.id || ''
}
function updateTOC({ toc, anchors, links, scrollOffset, collapseInactive }) {
const activeId = getActiveAnchorId(anchors, scrollOffset)
if (!activeId) return
links.forEach(link => {
const isActive = link.getAttribute('href') === `#${activeId}`
link.classList.toggle(ACTIVE_CLASS, isActive)
if (collapseInactive) {
const ul = link.closest('li')?.querySelector('ul')
if (ul) ul.style.display = isActive ? '' : 'none'
}
})
if (collapseInactive) {
const activeLink = toc.querySelector(`a[href="#${CSS.escape(activeId)}"]`)
let el = activeLink
while (el && el !== toc) {
if (el.tagName === 'UL') el.style.display = ''
if (el.tagName === 'LI') el.querySelector('ul')?.style.setProperty('display', '')
el = el.parentElement
}
}
}
function initTOC() {
const toc = document.querySelector(TOC_SELECTOR)
if (!toc) return
const collapseInactive = {{ if site.Params.smartTOCHideUnfocusedChildren }}true{{ else }}false{{ end }}
const anchors = [...document.querySelectorAll(ANCHOR_SELECTOR)]
const links = [...toc.querySelectorAll(TOC_LINK_SELECTOR)]
if (collapseInactive) {
toc.querySelectorAll(NESTED_LIST_SELECTOR).forEach(ul => ul.style.display = 'none')
}
links.forEach(link => {
link.addEventListener('click', () => {
isJumpingToAnchor = true
})
})
const config = {
toc,
anchors,
links,
scrollOffset: SCROLL_OFFSET_RATIO,
collapseInactive
}
window.addEventListener('scroll', () => updateTOC(config), { passive: true })
window.addEventListener('hashchange', () => updateTOC(config), { passive: true })
updateTOC(config)
}
document.readyState === 'loading'
? document.addEventListener('DOMContentLoaded', initTOC)
: initTOC()
})()
</script>
{{ end }}