Files
Cell a5d24f395e refactor(assets): use ES6 # private fields instead of TS private
Convert all TypeScript `private` and `private readonly` declarations to
ES6 `#` syntax for true runtime privacy. TypeScript's `private` keyword
only provides compile-time checking but fields remain accessible in
compiled JavaScript.
2026-07-23 14:33:19 +08:00

271 lines
11 KiB
TypeScript

import type { TocService } from '../core/tokens'
import { eventBus } from '../core/event-bus'
import { animateCSS, isMobile, isTocStatic } from '../utils'
const TOC_CONTAINER_IDS = ['toc-content-auto', 'toc-content-static', 'toc-content-drawer'] as const
/**
* Table of Contents module — TOC scroll tracking, active state sync, and dialog.
*
* Responsibilities:
* - Render TOC from template and sync layout state.
* - Clear TOC containers on `fixit:re-encrypt`.
* - Move TOC node to the correct container (static, auto, or drawer) on init.
* - Track scroll position and highlight the active heading in all TOC containers.
* - Initialize mobile TOC drawer dialog and its open/close handlers.
* - Clone TOC nodes to detach APlayer event listeners.
*/
export class TocModule implements TocService {
#activeTocId: string | null = null
/** Get all TOC content containers (auto, static, and drawer). */
#getTocContainers(): HTMLElement[] {
return TOC_CONTAINER_IDS
.map(id => document.getElementById(id))
.filter(Boolean) as HTMLElement[]
}
/**
* Determine which heading is currently active based on scroll position.
* @returns The active heading element, or `null` if none found.
*/
#getActiveTocHeading(): HTMLElement | null {
const $headingElements = Array.from(document.querySelectorAll<HTMLElement>('.heading-element[id]'))
if (!$headingElements.length)
return null
const headerOffset = (() => {
const headerId = isMobile() ? 'header-mobile' : 'header-desktop'
const $header = document.getElementById(headerId)
const headerMode = document.body.getAttribute(`data-${headerId}`)
if (!$header || window.getComputedStyle($header).display === 'none')
return 0
const shouldApplyOffset = headerMode === 'sticky' || (headerMode === 'auto' && !$header.classList.contains('header__fadeOutUp'))
return shouldApplyOffset ? $header.offsetHeight : 0
})()
const breadcrumbOffset = document.querySelector<HTMLElement>('.breadcrumb-container')?.offsetHeight || 0
const indexOffset = 20 + headerOffset + breadcrumbOffset
const threshold = window.scrollY + indexOffset + 1
let $activeHeading = $headingElements[0]
for (const $heading of $headingElements) {
const headingTop = window.scrollY + $heading.getBoundingClientRect().top
if (headingTop <= threshold) {
$activeHeading = $heading
}
else {
break
}
}
return $activeHeading
}
/**
* Find the TOC link that points to the given heading id.
* @param $tocRoot - The TOC root container element.
* @param id - The heading id (without `#`).
* @returns The matching anchor element, or `null`.
*/
#getTocLinkById($tocRoot: HTMLElement, id: string): HTMLAnchorElement | null {
if (!$tocRoot || !id)
return null
const targetHash = `#${id}`
return Array.from($tocRoot.querySelectorAll<HTMLAnchorElement>('a[href^="#"]')).find($link => $link.getAttribute('href') === targetHash) || null
}
/**
* Highlight the active TOC item and its parent chain.
* @param $tocRoot - The TOC root container element.
* @param activeId - The id of the currently active heading.
*/
#applyTocActiveState($tocRoot: HTMLElement, activeId: string) {
if (!$tocRoot)
return
$tocRoot.querySelectorAll('a[href^="#"]').forEach(($tocLink: Element) => {
$tocLink.classList.remove('active')
})
$tocRoot.querySelectorAll('li').forEach(($tocLi: Element) => {
$tocLi.classList.remove('has-active')
})
const $activeLink = this.#getTocLinkById($tocRoot, activeId)
if (!$activeLink)
return
$activeLink.classList.add('active')
let $parent = $activeLink.closest('li')
while ($parent) {
$parent.classList.add('has-active')
$parent = $parent.parentElement?.closest('li') || null
}
}
/**
* Scroll the active TOC link into the visible area of its container.
* @param $tocRoot - The TOC root container element.
* @param activeId - The id of the currently active heading.
* @param $scrollContainer - The scrollable container (defaults to `$tocRoot`).
*/
#scrollActiveTocLinkIntoView($tocRoot: HTMLElement, activeId: string, $scrollContainer: HTMLElement = $tocRoot) {
const $activeLink = this.#getTocLinkById($tocRoot, activeId)
if (!$activeLink || !$scrollContainer)
return
const containerRect = $scrollContainer.getBoundingClientRect()
const linkRect = $activeLink.getBoundingClientRect()
const offsetTop = linkRect.top - containerRect.top
const offsetBottom = linkRect.bottom - containerRect.bottom
if (offsetTop < 0) {
$scrollContainer.scrollTop += offsetTop
}
else if (offsetBottom > 0) {
$scrollContainer.scrollTop += offsetBottom
}
}
/** Update the TOC container's max-height CSS variable to fit the viewport. */
syncTocHeight() {
const $toc = document.getElementById('toc-auto')
const $tocContentAuto = document.getElementById('toc-content-auto')
if ($toc && $tocContentAuto) {
const maxHeight = Math.max(window.innerHeight - $tocContentAuto.getBoundingClientRect().top - 16)
$tocContentAuto.style.setProperty('--fi-toc-content-max-height', `${Math.floor(maxHeight)}px`)
}
}
/** Sync the active heading highlight across all TOC containers. */
syncTocActiveState() {
const $activeHeading = this.#getActiveTocHeading()
if (!$activeHeading?.id)
return
const activeId = $activeHeading.id
const $tocRoots = this.#getTocContainers()
.map($container => $container.querySelector<HTMLElement>('nav'))
.filter(Boolean) as HTMLElement[]
$tocRoots.forEach(($tocRoot) => {
this.#applyTocActiveState($tocRoot, activeId)
})
if (this.#activeTocId !== activeId) {
this.#activeTocId = activeId
if (!isTocStatic()) {
const $autoTocRoot = document.querySelector<HTMLElement>('#toc-content-auto > nav')
const $autoTocContainer = document.getElementById('toc-content-auto')
if ($autoTocRoot && $autoTocContainer) {
this.#scrollActiveTocLinkIntoView($autoTocRoot, activeId, $autoTocContainer)
}
}
if ((document.getElementById('toc-dialog') as HTMLDialogElement)?.open) {
const $dialogTocRoot = document.querySelector<HTMLElement>('#toc-content-drawer > nav')!
this.#scrollActiveTocLinkIntoView($dialogTocRoot, activeId, $dialogTocRoot)
}
}
}
/** Sync TOC layout state: drawer button visibility, height, and active heading. */
#syncTocLayout() {
document.querySelector<HTMLElement>('#toc-drawer-button')?.classList.toggle('hidden', !isTocStatic())
this.#activeTocId = null
this.syncTocHeight()
this.syncTocActiveState()
}
/** Render TOC from `<template data-toc>` into static/auto/drawer containers. */
renderToc() {
const $tocTemplate = document.querySelector<HTMLTemplateElement>('template[data-toc]')
if ($tocTemplate?.dataset.password)
return
const $tocCore = $tocTemplate?.content.querySelector('#TableOfContents')
if (!$tocTemplate || !$tocCore)
return
for (const $container of this.#getTocContainers()) {
$container.textContent = ''
const $clone = $tocCore.cloneNode(true) as HTMLElement
$clone.removeAttribute('id')
$container.appendChild($clone)
}
}
/** Bind the `toc-auto` title click handler for show/hide toggle. */
#initTocToggle() {
const $toc = document.getElementById('toc-auto')!
const $tocContentAuto = document.getElementById('toc-content-auto')!
document.querySelector<HTMLElement>('#toc-auto>.toc-title')?.addEventListener('click', () => {
const animation = ['animate__faster']
const tocHidden = $toc.classList.contains('toc-hidden')
animation.push(tocHidden ? 'animate__fadeIn' : 'animate__fadeOut')
if (tocHidden) {
$tocContentAuto.classList.remove('hidden', 'animate__fadeOut')
}
else {
$tocContentAuto.classList.remove('animate__fadeIn')
}
animateCSS($tocContentAuto, animation, true, () => {
$tocContentAuto.classList.contains('animate__fadeOut') && $tocContentAuto.classList.add('hidden')
})
$toc.classList.toggle('toc-hidden')
}, false)
}
/** Initialize the mobile TOC drawer dialog and its open/close handlers. */
#initTocDrawerLinkClose() {
const dialog = document.querySelector<HTMLDialogElement>('#toc-dialog')
if (!dialog)
return
document.querySelectorAll<HTMLAnchorElement>('#toc-content-drawer a[href^="#"]').forEach(($link) => {
if ($link.dataset.tocDialogCloseBound === 'true')
return
$link.addEventListener('click', () => dialog.close())
$link.dataset.tocDialogCloseBound = 'true'
})
}
/** Initialize the mobile TOC drawer dialog and its open/close handlers. */
#initTocDrawer() {
const dialog = document.querySelector<HTMLDialogElement>('#toc-dialog')
const openButton = document.querySelector<HTMLElement>('#toc-drawer-button')
if (!dialog || !openButton)
return
const closeButton = dialog.querySelector<HTMLElement>('.toc-close-btn')
closeButton?.addEventListener('click', () => dialog.close())
openButton.addEventListener('click', () => {
dialog.showModal()
openButton.setAttribute('aria-expanded', 'true')
this.syncTocHeight()
this.syncTocActiveState()
const $dialogTocRoot = document.querySelector<HTMLElement>('#toc-content-drawer > nav')!
this.#scrollActiveTocLinkIntoView($dialogTocRoot, this.#activeTocId!, $dialogTocRoot)
;(document.activeElement as HTMLElement)?.blur()
})
dialog.addEventListener('close', () => {
openButton.setAttribute('aria-expanded', 'false')
})
}
/** Reset heading-mark nodes to fix the APlayer-caused anchor click issue. */
#resetHeadingClicks() {
if (typeof window.APlayer === 'function') {
document.querySelectorAll('.heading-mark').forEach(($headingMark: Element) => {
const $newHeadingMark = $headingMark.cloneNode(true)
$headingMark.parentElement!.replaceChild($newHeadingMark, $headingMark)
})
}
}
/** Initialize all TOC components and register event listeners. */
setup() {
this.renderToc()
this.#initTocToggle()
this.#syncTocLayout()
this.#initTocDrawer()
this.#initTocDrawerLinkClose()
this.#resetHeadingClicks()
eventBus.on('fixit:resize', () => this.#syncTocLayout())
eventBus.on('fixit:scroll', () => this.syncTocActiveState())
eventBus.on('fixit:content-decrypted', ({ detail }) => {
if (detail.isPage) {
this.#syncTocLayout()
this.#initTocDrawerLinkClose()
}
})
eventBus.on('fixit:re-encrypt', () => {
this.#syncTocLayout()
})
}
}