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('.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('.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('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('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('#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('#toc-content-drawer > nav')! this.#scrollActiveTocLinkIntoView($dialogTocRoot, activeId, $dialogTocRoot) } } } /** Sync TOC layout state: drawer button visibility, height, and active heading. */ #syncTocLayout() { document.querySelector('#toc-drawer-button')?.classList.toggle('hidden', !isTocStatic()) this.#activeTocId = null this.syncTocHeight() this.syncTocActiveState() } /** Render TOC from `