Files
FixIt/assets/js/modules/toc.ts
T
Cell 8af8e806d7 feat(assets): integrate UnoCSS for utility classes and presetIcons (#795)
* refactor(assets): integrate UnoCSS for utility classes

- Add UnoCSS with presetWind3 as pre-built CSS (assets/css/unocss.css)
- Add uno.config.ts with theme breakpoints, colors, safelist, and blocklist
- Replace SCSS utility classes with UnoCSS atomic classes:
  - d-none → hidden
  - d-none-desktop → sm:hidden
  - d-none-mobile → max-sm:hidden
  - order-* (safelist)
  - variant-numeric → tabular-nums lining-nums
- Rename .blur to .is-blur to avoid UnoCSS conflict
- Add @unocss-skip comments for SVG path elements to prevent false positives
- Remove redundant _utilities.scss ($orders map) and simplify _common.scss
- Update documentation (CLAUDE.md, CONTRIBUTING.md, copilot-instructions.md)

* refactor(assets): replace 404/offline SCSS with UnoCSS atomic classes

- Replace _404.scss styles with atomic classes on template elements
- Replace _offline.scss styles with atomic classes on template elements
- Keep selectors (id/class) as user customization hooks
- Delete assets/scss/pages/_404.scss and _offline.scss

* refactor(assets): simplify SCSS with UnoCSS atomic classes

- Replace _bilibili.scss with atomic classes (relative, aspect-video, inset-0)
- Replace _version.scss with atomic classes (whitespace-nowrap, align-text-bottom)
- Replace _noscript-warning.scss with atomic classes (bg-danger, fixed, z-200)
- Add semantic z-index shortcuts (z-hide, z-auto, z-base, z-loading, z-sticky, z-fixed)
- Add h1-h6 to UnoCSS blocklist to prevent false positives
- Use Source path for UnoCSS CSS loading to enable Hugo minify

* chore(assets): minor SCSS and template cleanup

* fix(assets): add secondary color to UnoCSS theme config

text-secondary was broken because the secondary color mapping was missing.

* feat(assets): add UnoCSS presetIcons support and z-index shortcuts

- Add presetIcons preset with empty collections (ready for custom icon sets)
- Add @iconify/json and @iconify/utils devDependencies
- Add semantic z-index shortcuts (z-hide, z-auto, z-base, z-loading, z-sticky, z-fixed)
- Add secondary color to UnoCSS theme config

* refactor(assets): replace SVG icons with UnoCSS presetIcons

- Add Lucide and Octicons icon collections to presetIcons config
- Replace arrow-up/down and enter-key SVGs with Lucide icons
- Replace alert icons (info, light-bulb, report, alert, stop) with Octicons
- Delete replaced SVG files from assets/images/icons/
- Keep custom SVGs (csdn, plume, rootme)

* refactor(layouts): redesign 404 and offline pages with UnoCSS

- Replace translate-y-[30vh] with self-stretch flex centering below header
- Remove arbitrary values (text-[3.6rem], text-[1.8rem], etc.)
- Restore @page print styles in _common.scss
- Simplify offline page layout (icon + title + text)
- Fix hardcoded #57606a → text-secondary
2026-06-27 06:27:12 +08:00

282 lines
11 KiB
TypeScript

import type { TocService } from '../core/tokens'
import { animateCSS, isTocStatic } from '../utils'
/**
* Table of Contents module — TOC scroll tracking, active state sync, and dialog.
*
* Responsibilities:
* - 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 {
private activeTocId: string | null = null
/** Get the pixel height of the currently visible sticky header. */
getVisibleHeaderOffset(): number {
const $desktopHeader = document.getElementById('header-desktop')
const $mobileHeader = document.getElementById('header-mobile')
const $header = [$desktopHeader, $mobileHeader].find($el => $el && window.getComputedStyle($el).display !== 'none')
if (!$header)
return 0
const isDesktop = $header.id === 'header-desktop'
const headerMode = isDesktop ? document.body.dataset.headerDesktop : document.body.dataset.headerMobile
if (!['sticky', 'auto'].includes(headerMode!))
return 0
if (headerMode === 'auto' && $header.classList.contains('header__fadeOutUp'))
return 0
return $header.offsetHeight
}
/** Get the pixel height of the breadcrumb container. */
getBreadcrumbHeight(): number {
return document.querySelector<HTMLElement>('.breadcrumb-container')?.offsetHeight || 0
}
/** Get the combined vertical offset used to determine the active TOC heading. */
getTocIndexOffset(): number {
return 20 + this.getVisibleHeaderOffset() + this.getBreadcrumbHeight()
}
/** Get all heading elements that have an `id` attribute. */
getTocHeadingElements(): HTMLElement[] {
return Array.from(document.querySelectorAll<HTMLElement>('.heading-element[id]'))
}
/**
* Determine which heading is currently active based on scroll position.
* @param $headingElements - Array of heading elements with `id` attributes.
* @param indexOffset - Vertical offset from the top for the active threshold.
* @returns The active heading element, or `null` if none found.
*/
getActiveTocHeading($headingElements: HTMLElement[], indexOffset = this.getTocIndexOffset()): HTMLElement | null {
if (!$headingElements.length)
return null
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
}
/** Get all TOC root containers (static, auto, and drawer). */
getTocRoots(): HTMLElement[] {
return [
document.getElementById('TableOfContents'),
document.querySelector<HTMLElement>('#toc-content-static > nav'),
document.querySelector<HTMLElement>('#toc-content-drawer > nav'),
].filter(Boolean) as HTMLElement[]
}
/**
* 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 $headingElements = this.getTocHeadingElements()
const $activeHeading = this.getActiveTocHeading($headingElements)
if (!$activeHeading?.id)
return
const activeId = $activeHeading.id
const $tocRoots = this.getTocRoots()
$tocRoots.forEach(($tocRoot) => {
this.applyTocActiveState($tocRoot, activeId)
})
if (this.activeTocId !== activeId) {
this.activeTocId = activeId
if (!isTocStatic()) {
const $autoTocRoot = document.getElementById('TableOfContents')
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)
}
}
}
/** Initialize TOC layout: move the TOC node to the correct container and sync state. */
initToc() {
const $tocCore = document.getElementById('TableOfContents')
if ($tocCore === null)
return
// TOC Drawer Button Visibility
const openButton = document.querySelector<HTMLElement>('#toc-drawer-button')
if (openButton) {
openButton.classList.toggle('hidden', !isTocStatic())
}
this.activeTocId = null
// TOC Static and TOC Dialog
if (isTocStatic()) {
const $tocContentStatic = document.getElementById('toc-content-static')!
if ($tocCore.parentElement !== $tocContentStatic) {
$tocCore.parentElement!.removeChild($tocCore)
$tocContentStatic.appendChild($tocCore)
}
this.syncTocHeight()
this.syncTocActiveState()
return
}
// TOC Auto
const $tocContentAuto = document.getElementById('toc-content-auto')!
if ($tocCore.parentElement !== $tocContentAuto) {
$tocCore.parentElement!.removeChild($tocCore)
$tocContentAuto.appendChild($tocCore)
}
const $toc = document.getElementById('toc-auto')!
$toc.style.visibility = 'visible'
animateCSS($toc, ['animate__fadeIn', 'animate__faster'], true)
this.syncTocHeight()
this.syncTocActiveState()
}
/** Bind the TOC title click handler for show/hide toggle. */
initTocListener() {
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. */
initTocDialog() {
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()
})
document.querySelectorAll<HTMLAnchorElement>('#toc-content-drawer a[href^="#"]').forEach(($link) => {
$link.addEventListener('click', () => dialog.close())
})
dialog.addEventListener('close', () => {
openButton.setAttribute('aria-expanded', 'false')
})
}
/** Clone TOC and heading-mark nodes to detach APlayer event listeners. */
fixTocScroll() {
if (typeof window.APlayer === 'function') {
let $tocCore = document.getElementById('TableOfContents')
if ($tocCore) {
const $newTocCore = $tocCore.cloneNode(true) as HTMLElement
$tocCore.parentElement!.replaceChild($newTocCore, $tocCore)
$tocCore = $newTocCore
}
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.fixTocScroll()
this.initToc()
this.initTocListener()
this.initTocDialog()
}
}