refactor(assets): streamline decrypt and toc event flow

This commit is contained in:
Cell
2026-07-07 14:52:35 +08:00
parent e58bdd4db5
commit 43c439eee0
20 changed files with 189 additions and 201 deletions
+2 -2
View File
@@ -3,8 +3,8 @@ export interface FixItEventMap {
'fixit:switch-theme': { isDark: boolean, mode: string, isChanged: boolean }
'fixit:scroll': void
'fixit:resize': void
'fixit:decrypted': { target: Element }
'fixit:partial-decrypted': { target: Element }
'fixit:content-decrypted': { target: HTMLElement, isPage: boolean }
'fixit:toc-decrypted': { html: string }
'fixit:re-encrypt': void
'fixit:code-tab-sync': { lang: string, source: HTMLElement }
'fixit:sw-update': void
+1 -1
View File
@@ -45,9 +45,9 @@ export interface CodeService {
// ─── TocService ───
export interface TocService {
renderToc: () => void
syncTocHeight: () => void
syncTocActiveState: () => void
initToc: () => void
setup: () => void
}
+2 -3
View File
@@ -3,7 +3,7 @@
*
* Responsibilities:
* - Discover `.shortcode-aplayer` nodes and initialize APlayer instances once.
* - Re-run initialization after decrypted or partially decrypted content is revealed.
* - Re-run initialization when `fixit:content-decrypted` is emitted.
* - Keep behavior idempotent through `data-processed` markers.
*/
import { eventBus } from '../core/event-bus'
@@ -24,6 +24,5 @@ function initAPlayer() {
document.addEventListener('DOMContentLoaded', () => {
initAPlayer()
eventBus.on('fixit:decrypted', initAPlayer)
eventBus.on('fixit:partial-decrypted', initAPlayer)
eventBus.on('fixit:content-decrypted', initAPlayer)
}, false)
+8 -3
View File
@@ -5,7 +5,7 @@
* - Initialize ECharts instances with light/dark theme support.
* - Re-render all charts on theme switch.
* - Resize charts on window resize events.
* - Re-run initialization after decrypted content is revealed.
* - Re-run initialization when `fixit:content-decrypted` is emitted.
*/
import { eventBus } from '../core/event-bus'
import { getStagingDOM, isDarkMode, isObjectLiteral } from '../utils'
@@ -84,6 +84,11 @@ document.addEventListener('DOMContentLoaded', () => {
eventBus.on('fixit:resize', () => {
echartsArr.forEach(chart => chart.resize())
})
eventBus.on('fixit:decrypted', applyEchartsTheme)
eventBus.on('fixit:partial-decrypted', ({ detail }) => initEchartsInTarget(detail.target))
eventBus.on('fixit:content-decrypted', ({ detail }) => {
if (detail.isPage) {
applyEchartsTheme()
return
}
initEchartsInTarget(detail.target)
})
}, false)
+2 -6
View File
@@ -4,7 +4,7 @@
* Responsibilities:
* - Initialize folder expand/collapse interactions for `.file-tree` blocks.
* - Recalculate connector line heights when tree visibility/layout changes.
* - Sync tree state across tab switches, print preparation, and decrypted content updates.
* - Sync tree state across tab switches, print preparation, and `fixit:content-decrypted` updates.
*/
import type { TabContainerChangedEvent } from '../types'
import { eventBus } from '../core/event-bus'
@@ -88,11 +88,7 @@ function bindEvents() {
}
}, false)
eventBus.on('fixit:decrypted', () => {
initFileTree()
})
eventBus.on('fixit:partial-decrypted', ({ detail }) => {
eventBus.on('fixit:content-decrypted', ({ detail }) => {
initFileTree(detail.target)
})
}
+88 -40
View File
@@ -2,10 +2,10 @@
* Encrypted content decryptor for FixIt pages and shortcodes.
*
* Responsibilities:
* - Validate password input and decrypt AES-256-GCM payloads into target containers.
* - Validate password input and decrypt AES-256-GCM payloads.
* - Support both full-page and shortcode-scoped encrypted blocks.
* - Persist and validate page-level decrypt cache with expiration.
* - Emit and react to FixIt events for decrypted/partial-decrypted/reset flows.
* - Emit FixIt events for content/TOC rendering and re-encrypt flows.
*
* Encrypted payloads are stored in `<template data-password="..." data-cipher="...">` elements.
* Using `<template>` prevents the browser from rendering inner content (mermaid, echarts, etc.).
@@ -43,6 +43,33 @@ class FixItDecryptor {
customElements.get('fixit-encryptor') || customElements.define('fixit-encryptor', class extends HTMLElement {})
}
/** Toggle encrypted/decrypted visibility helper classes in the given scope. */
#toggleClass(container: Element | Document, show: boolean): void {
const fromClass = show ? 'encrypted-hidden' : 'decrypted-shown'
const toClass = show ? 'decrypted-shown' : 'encrypted-hidden'
container.querySelectorAll(`.${fromClass}`).forEach(($element: Element) => {
$element.classList.replace(fromClass, toClass)
})
}
/** Copy TOC HTML to all TOC containers. */
#renderToc(html: string): void {
const $template = document.createElement('template')
$template.innerHTML = html
const $tocCore = $template.content.querySelector('#TableOfContents')
if (!$tocCore)
return
for (const id of ['toc-content-static', 'toc-content-auto', 'toc-content-drawer']) {
const $container = document.getElementById(id)
if (!$container)
continue
$container.textContent = ''
const $clone = $tocCore.cloneNode(true) as HTMLElement
$clone.removeAttribute('id')
$container.appendChild($clone)
}
}
/**
* Decrypt a v2 payload using PBKDF2 key derivation.
* Format: base64(salt).base64(iv).base64(ciphertext+tag)
@@ -76,6 +103,11 @@ class FixItDecryptor {
return new TextDecoder().decode(plainBuffer)
}
/**
* Compute SHA-256 hex digest for user input.
* @param input - Raw password input.
* @returns Lowercase hex SHA-256 digest.
*/
async #sha256Hex(input: string): Promise<string> {
const bytes = new TextEncoder().encode(input)
const hashBuffer = await crypto.subtle.digest('SHA-256', bytes)
@@ -100,6 +132,45 @@ class FixItDecryptor {
.join('')
}
/**
* Decrypt TOC template content and refill TOC containers directly.
* The template itself stays encrypted so re-encrypt can restore the initial state.
* @param passwordHash - SHA-256 hash for AES key derivation.
*/
async #decryptToc(passwordHash: string): Promise<void> {
const $tocTemplate = document.querySelector<HTMLTemplateElement>('template[data-toc]')
if (!$tocTemplate)
return
try {
const cipher = $tocTemplate.dataset.cipher
const payload = $tocTemplate.innerHTML
let html: string
if (!cipher) {
// Dev mode: no post-build encryption, TOC is plaintext
html = payload
}
else if (cipher === 'aes-256-gcm-v2') {
html = await this.#decryptV2(payload, passwordHash)
}
else {
throw new Error(`Unsupported cipher: ${cipher}`)
}
this.#renderToc(html)
eventBus.emit('fixit:toc-decrypted', { html })
}
catch (err) {
console.error('[FixItDecryptor] Failed to restore TOC:', err)
}
}
/**
* Decrypt template payload and write plaintext into the target container.
* @param $template - Encrypted content template element.
* @param $target - Target container that should receive decrypted content.
* @param passwordHash - SHA-256 hash for AES key derivation.
*/
async #decryptContent($template: HTMLTemplateElement, $target: HTMLElement, passwordHash: string): Promise<void> {
try {
const cipher = $template.dataset.cipher
@@ -119,6 +190,9 @@ class FixItDecryptor {
$target.innerHTML = html
$template.parentElement!.classList.add('decrypted')
const isPage = $target.id === 'content'
this.#toggleClass(isPage ? document : $target, true)
eventBus.emit('fixit:content-decrypted', { target: $target, isPage })
}
catch (err) {
const $encryptor = $template.parentElement!
@@ -128,10 +202,6 @@ class FixItDecryptor {
}
return console.error(err)
}
if ($target.id === 'content')
eventBus.emit('fixit:decrypted', { target: $target })
else
eventBus.emit('fixit:partial-decrypted', { target: $target })
}
/**
@@ -185,10 +255,7 @@ class FixItDecryptor {
*/
init({ all, shortcode }: { all?: boolean, shortcode?: boolean }): void {
if (shortcode) {
eventBus.on('fixit:decrypted', ({ detail }) => {
this.initShortcodes(detail.target)
})
eventBus.on('fixit:partial-decrypted', ({ detail }) => {
eventBus.on('fixit:content-decrypted', ({ detail }) => {
this.initShortcodes(detail.target)
})
}
@@ -200,27 +267,7 @@ class FixItDecryptor {
}
}
/**
* Decrypt the TOC template content in place.
* After decryption, TocModule.initToc() (via fixit:decrypted event) copies it to containers.
* @param passwordHash - SHA-256 hash for AES key derivation.
*/
async #restoreToc(passwordHash: string): Promise<void> {
const $tocTemplate = document.querySelector<HTMLTemplateElement>('template[data-toc][data-cipher]')
if (!$tocTemplate)
return
try {
const cipher = $tocTemplate.dataset.cipher
const payload = $tocTemplate.innerHTML
if (cipher === 'aes-256-gcm-v2')
$tocTemplate.innerHTML = await this.#decryptV2(payload, passwordHash)
}
catch (err) {
console.error('[FixItDecryptor] Failed to restore TOC:', err)
}
}
/** Initialize whole-page decryption with cache validation and encrypt/re-encrypt buttons. */
/** Initialize whole-page decryption with cache validation and input handlers. */
initPage(): void {
this.validateCache()
const $encryptor = document.querySelector<HTMLElement>('article > fixit-encryptor')!
@@ -236,7 +283,7 @@ class FixItDecryptor {
sha256: passwordHash,
}),
)
await this.#restoreToc(passwordHash)
await this.#decryptToc(passwordHash)
await this.#decryptContent($template, $content, passwordHash)
}).catch(console.error)
}
@@ -253,11 +300,17 @@ class FixItDecryptor {
decryptorHandler()
})
// Only for full-page decryption: re-encrypt button to clear cache and reset content
// Re-encrypt button only orchestrates state/cache reset; modules handle DOM cleanup.
const $reEncryptBtn = $encryptor.querySelector<HTMLElement>('.fixit-encryptor-btn')
$reEncryptBtn?.addEventListener('click', (e) => {
e.preventDefault()
window.CellTooltip?.getOrCreateInstance($reEncryptBtn).dispose()
for (const id of ['toc-content-static', 'toc-content-auto', 'toc-content-drawer']) {
const $el = document.getElementById(id)
if ($el) {
$el.textContent = ''
}
}
$content.animate(
[
{ opacity: 1, transform: 'scaleY(1)', transformOrigin: 'top' },
@@ -269,13 +322,8 @@ class FixItDecryptor {
$content.style.opacity = ''
$content.style.transform = ''
})
for (const id of ['toc-content-static', 'toc-content-auto', 'toc-content-drawer']) {
const $el = document.getElementById(id)
if ($el?.querySelector('nav')) {
$el.textContent = ''
}
}
$encryptor.classList.remove('decrypted')
this.#toggleClass(document, false)
window.localStorage?.removeItem(`fixit-decryptor/#${location.pathname}`)
eventBus.emit('fixit:re-encrypt')
})
@@ -330,7 +378,7 @@ class FixItDecryptor {
return this
}
// Use sha256 hash for AES key derivation (not the verification hash)
void this.#restoreToc(cachedStat.sha256)
void this.#decryptToc(cachedStat.sha256)
.then(() => this.#decryptContent($template, $content, cachedStat.sha256))
return this
}
+2 -3
View File
@@ -3,7 +3,7 @@
*
* Responsibilities:
* - Initialize lightGallery for page image zoom and thumbnails.
* - Re-initialize on decrypted or partially decrypted content.
* - Re-initialize when `fixit:content-decrypted` is emitted.
*/
import { eventBus } from '../core/event-bus'
@@ -36,6 +36,5 @@ function initLightGallery() {
document.addEventListener('DOMContentLoaded', () => {
initLightGallery()
eventBus.on('fixit:decrypted', initLightGallery)
eventBus.on('fixit:partial-decrypted', initLightGallery)
eventBus.on('fixit:content-decrypted', initLightGallery)
}, false)
+2 -3
View File
@@ -4,7 +4,7 @@
* Responsibilities:
* - Initialize Mapbox GL maps with controls and optional markers.
* - Apply light/dark style on theme switch.
* - Re-run initialization after decrypted content is revealed.
* - Re-run initialization when `fixit:content-decrypted` is emitted.
*/
import { eventBus } from '../core/event-bus'
import { isDarkMode } from '../utils'
@@ -83,6 +83,5 @@ document.addEventListener('DOMContentLoaded', () => {
mapbox.addControl(new window.MapboxLanguage())
})
})
eventBus.on('fixit:decrypted', () => initMapbox())
eventBus.on('fixit:partial-decrypted', ({ detail }) => initMapbox(detail.target))
eventBus.on('fixit:content-decrypted', ({ detail }) => initMapbox(detail.target))
}, false)
+1 -4
View File
@@ -68,10 +68,7 @@ function initMathJax(el?: Element) {
document.addEventListener('DOMContentLoaded', () => {
bootstrapMathJax()
eventBus.on('fixit:decrypted', () => {
initMathJax()
})
eventBus.on('fixit:partial-decrypted', ({ detail }) => {
eventBus.on('fixit:content-decrypted', ({ detail }) => {
initMathJax(detail.target)
})
})
+2 -3
View File
@@ -13,7 +13,7 @@
* - Bind pan/zoom behavior and synchronize transforms across light/dark layers
* during theme switches.
* - Wire diagram tab controls (diagram/code switch, zoom/reset/download actions).
* - React to FixIt events (theme switch, decrypted content, partial decrypted content)
* - React to FixIt events (theme switch and `fixit:content-decrypted`)
* and re-observe/re-render affected containers when context changes.
*
* Public entrypoints:
@@ -793,8 +793,7 @@ function bindGlobalEventsOnce(): void {
bindTabContainerChanged()
initMermaid()
eventBus.on('fixit:decrypted', initMermaid)
eventBus.on('fixit:partial-decrypted', initMermaid)
eventBus.on('fixit:content-decrypted', initMermaid)
}
/**
+2 -5
View File
@@ -4,7 +4,7 @@
* Responsibilities:
* - Automatically add spacing between CJK (Chinese, Japanese, Korean) and ASCII characters.
* - Support both full-page spacing and selector-based spacing.
* - Re-run spacing after decrypted or partially decrypted content is revealed.
* - Re-run spacing when `fixit:content-decrypted` is emitted.
*/
import { eventBus } from '../core/event-bus'
@@ -30,10 +30,7 @@ function initPangu(target?: Element) {
document.addEventListener('DOMContentLoaded', () => {
initPangu()
eventBus.on('fixit:decrypted', () => {
initPangu()
})
eventBus.on('fixit:partial-decrypted', ({ detail }) => {
eventBus.on('fixit:content-decrypted', ({ detail }) => {
initPangu(detail.target)
})
}, false)
+2 -5
View File
@@ -3,7 +3,7 @@
*
* Responsibilities:
* - Parse emoji shortcodes into Twemoji images when enabled.
* - Re-run parsing after decrypted or partially decrypted content is revealed.
* - Re-run parsing when `fixit:content-decrypted` is emitted.
*/
import { eventBus } from '../core/event-bus'
@@ -14,10 +14,7 @@ function initTwemoji(target: Element | Document = document) {
document.addEventListener('DOMContentLoaded', () => {
initTwemoji()
eventBus.on('fixit:decrypted', () => {
initTwemoji()
})
eventBus.on('fixit:partial-decrypted', ({ detail }) => {
eventBus.on('fixit:content-decrypted', ({ detail }) => {
initTwemoji(detail.target)
})
}, false)
+2 -3
View File
@@ -3,7 +3,7 @@
*
* Responsibilities:
* - Initialize TypeIt typewriter instances, grouped and chained by data attributes.
* - Re-run initialization after decrypted content is revealed.
* - Re-run initialization when `fixit:content-decrypted` is emitted.
*/
import { eventBus } from '../core/event-bus'
import { getStagingDOM } from '../utils'
@@ -66,6 +66,5 @@ function initTypeit(target: Element | Document = document) {
document.addEventListener('DOMContentLoaded', () => {
initTypeit()
eventBus.on('fixit:decrypted', () => initTypeit())
eventBus.on('fixit:partial-decrypted', ({ detail }) => initTypeit(detail.target))
eventBus.on('fixit:content-decrypted', ({ detail }) => initTypeit(detail.target))
}, false)
+1 -4
View File
@@ -187,10 +187,7 @@ export class ContentModule implements ContentService {
setup() {
this.initContent()
this.initSVGIcon()
eventBus.on('fixit:decrypted', ({ detail }) => {
this.initContent(detail.target)
})
eventBus.on('fixit:partial-decrypted', ({ detail }) => {
eventBus.on('fixit:content-decrypted', ({ detail }) => {
this.initContent(detail.target)
})
}
+2 -27
View File
@@ -1,45 +1,20 @@
import type { CoreService, EncryptionService } from '../core/tokens'
import { eventBus } from '../core/event-bus'
/**
* Encryption module — page decryption via FixItDecryptor and encrypted content toggling.
* Encryption module — bootstrap page/shortcode decryption via FixItDecryptor.
*
* Responsibilities:
* - Initialize FixItDecryptor for full-page and shortcode-scoped decryption.
* - Toggle visibility of encrypted content sections.
*/
export class EncryptionModule implements EncryptionService {
constructor(private readonly core: CoreService) {}
/**
* Toggle between encrypted-hidden and decrypted-shown classes.
* @param container - The root element containing encrypted elements.
* @param show - `true` to show decrypted content, `false` to hide.
*/
#toggleEncryptedClass(container: Element | Document, show: boolean) {
const fromClass = show ? 'encrypted-hidden' : 'decrypted-shown'
const toClass = show ? 'decrypted-shown' : 'encrypted-hidden'
container.querySelectorAll(`.${fromClass}`).forEach(($element: Element) => {
$element.classList.replace(fromClass, toClass)
})
}
/** Initialize the FixItDecryptor and wire up decryption/re-encryption events. */
/** Initialize FixItDecryptor with encryption config. */
setup() {
if (!this.core.config.encryption || !window.FixItDecryptor)
return
const decryptor = new window.FixItDecryptor()
eventBus.on('fixit:decrypted', () => {
this.#toggleEncryptedClass(document, true)
})
eventBus.on('fixit:partial-decrypted', ({ detail }) => {
this.#toggleEncryptedClass(detail.target, true)
})
eventBus.on('fixit:re-encrypt', () => {
this.#toggleEncryptedClass(document, false)
})
decryptor.init(this.core.config.encryption)
}
}
+68 -84
View File
@@ -1,11 +1,15 @@
import type { TocService } from '../core/tokens'
import { eventBus } from '../core/event-bus'
import { animateCSS, isTocStatic } from '../utils'
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.
@@ -14,46 +18,33 @@ import { animateCSS, isTocStatic } from '../utils'
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]'))
/** 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.
* @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 {
#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) {
@@ -68,22 +59,13 @@ export class TocModule implements TocService {
return $activeHeading
}
/** Get all TOC root containers (static, auto, and drawer). */
getTocRoots(): HTMLElement[] {
return [
document.querySelector<HTMLElement>('#toc-content-auto > nav'),
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 {
#getTocLinkById($tocRoot: HTMLElement, id: string): HTMLAnchorElement | null {
if (!$tocRoot || !id)
return null
const targetHash = `#${id}`
@@ -95,7 +77,7 @@ export class TocModule implements TocService {
* @param $tocRoot - The TOC root container element.
* @param activeId - The id of the currently active heading.
*/
applyTocActiveState($tocRoot: HTMLElement, activeId: string) {
#applyTocActiveState($tocRoot: HTMLElement, activeId: string) {
if (!$tocRoot)
return
$tocRoot.querySelectorAll('a[href^="#"]').forEach(($tocLink: Element) => {
@@ -104,7 +86,7 @@ export class TocModule implements TocService {
$tocRoot.querySelectorAll('li').forEach(($tocLi: Element) => {
$tocLi.classList.remove('has-active')
})
const $activeLink = this.getTocLinkById($tocRoot, activeId)
const $activeLink = this.#getTocLinkById($tocRoot, activeId)
if (!$activeLink)
return
$activeLink.classList.add('active')
@@ -121,8 +103,8 @@ export class TocModule implements TocService {
* @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)
#scrollActiveTocLinkIntoView($tocRoot: HTMLElement, activeId: string, $scrollContainer: HTMLElement = $tocRoot) {
const $activeLink = this.#getTocLinkById($tocRoot, activeId)
if (!$activeLink || !$scrollContainer)
return
const containerRect = $scrollContainer.getBoundingClientRect()
@@ -149,14 +131,15 @@ export class TocModule implements TocService {
/** Sync the active heading highlight across all TOC containers. */
syncTocActiveState() {
const $headingElements = this.getTocHeadingElements()
const $activeHeading = this.getActiveTocHeading($headingElements)
const $activeHeading = this.#getActiveTocHeading()
if (!$activeHeading?.id)
return
const activeId = $activeHeading.id
const $tocRoots = this.getTocRoots()
const $tocRoots = this.#getTocContainers()
.map($container => $container.querySelector<HTMLElement>('nav'))
.filter(Boolean) as HTMLElement[]
$tocRoots.forEach(($tocRoot) => {
this.applyTocActiveState($tocRoot, activeId)
this.#applyTocActiveState($tocRoot, activeId)
})
if (this.activeTocId !== activeId) {
this.activeTocId = activeId
@@ -164,45 +147,42 @@ export class TocModule implements TocService {
const $autoTocRoot = document.querySelector<HTMLElement>('#toc-content-auto > nav')
const $autoTocContainer = document.getElementById('toc-content-auto')
if ($autoTocRoot && $autoTocContainer) {
this.scrollActiveTocLinkIntoView($autoTocRoot, activeId, $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)
this.#scrollActiveTocLinkIntoView($dialogTocRoot, activeId, $dialogTocRoot)
}
}
}
/** Sync TOC layout state: drawer button visibility, height, and active heading. */
syncTocLayout() {
#syncTocLayout() {
document.querySelector<HTMLElement>('#toc-drawer-button')?.classList.toggle('hidden', !isTocStatic())
this.activeTocId = null
this.syncTocHeight()
this.syncTocActiveState()
}
/** Initialize TOC layout: read from `<template data-toc>`, copy to the correct container and dialog. */
initToc() {
/** 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
// Copy TOC to target containers
const targets = ['toc-content-static', 'toc-content-auto', 'toc-content-drawer']
for (const id of targets) {
const $container = document.getElementById(id)
if ($container && !$container.querySelector('nav')) {
const $clone = $tocCore.cloneNode(true) as HTMLElement
$clone.removeAttribute('id')
$container.appendChild($clone)
}
for (const $container of this.#getTocContainers()) {
$container.textContent = ''
const $clone = $tocCore.cloneNode(true) as HTMLElement
$clone.removeAttribute('id')
$container.appendChild($clone)
}
}
/** Bind the TOC title click handler for show/hide toggle. */
initTocListener() {
/** 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', () => {
@@ -223,7 +203,7 @@ export class TocModule implements TocService {
}
/** Initialize the mobile TOC drawer dialog and its open/close handlers. */
initTocDialogLink() {
#initTocDrawerLinkClose() {
const dialog = document.querySelector<HTMLDialogElement>('#toc-dialog')
if (!dialog)
return
@@ -236,7 +216,7 @@ export class TocModule implements TocService {
}
/** Initialize the mobile TOC drawer dialog and its open/close handlers. */
initTocDialog() {
#initTocDrawer() {
const dialog = document.querySelector<HTMLDialogElement>('#toc-dialog')
const openButton = document.querySelector<HTMLElement>('#toc-drawer-button')
if (!dialog || !openButton)
@@ -249,7 +229,7 @@ export class TocModule implements TocService {
this.syncTocHeight()
this.syncTocActiveState()
const $dialogTocRoot = document.querySelector<HTMLElement>('#toc-content-drawer > nav')!
this.scrollActiveTocLinkIntoView($dialogTocRoot, this.activeTocId!, $dialogTocRoot)
this.#scrollActiveTocLinkIntoView($dialogTocRoot, this.activeTocId!, $dialogTocRoot)
;(document.activeElement as HTMLElement)?.blur()
})
dialog.addEventListener('close', () => {
@@ -257,8 +237,8 @@ export class TocModule implements TocService {
})
}
/** Clone heading-mark nodes to detach APlayer event listeners. */
fixTocScroll() {
/** 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)
@@ -269,18 +249,22 @@ export class TocModule implements TocService {
/** Initialize all TOC components and register event listeners. */
setup() {
this.initToc()
this.syncTocLayout()
this.initTocListener()
this.initTocDialog()
this.initTocDialogLink()
this.fixTocScroll()
eventBus.on('fixit:resize', () => this.syncTocLayout())
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:decrypted', () => {
this.initToc()
this.syncTocLayout()
this.initTocDialogLink()
eventBus.on('fixit:content-decrypted', ({ detail }) => {
if (detail.isPage) {
this.#syncTocLayout()
this.#initTocDrawerLinkClose()
}
})
eventBus.on('fixit:re-encrypt', () => {
this.#syncTocLayout()
})
}
}
@@ -151,7 +151,6 @@
text-indent: -0.8em;
list-style: none;
overflow-y: auto;
overscroll-behavior: contain;
max-height: 60vh;
@include scrollbar-width(none, 0);
+1 -2
View File
@@ -22,7 +22,7 @@
gap: 0.25em;
padding: 0.2em 0.5em;
user-select: none;
> i.fa-layer-group {
flex-shrink: 0;
}
@@ -52,7 +52,6 @@
text-indent: -0.8em;
list-style: none;
overflow-y: auto;
overscroll-behavior: contain;
max-height: 60vh;
@include scrollbar-width(none, 0);
-1
View File
@@ -125,7 +125,6 @@
.toc-content {
overflow-y: auto;
overscroll-behavior: contain;
max-height: fi-var(toc-content-max-height, 90dvh);
@include scrollbar-width(none, 0);
+1 -1
View File
@@ -1,4 +1,4 @@
{{- hugo.Store.Set "version" "v1.0.0-mr84fuvo" -}}
{{- hugo.Store.Set "version" "v1.0.0-mraakpxu" -}}
{{- .Store.Set "this" dict -}}
{{- partial "init/detection-env.html" . -}}